* fix(node): close two spam-vector root causes (trust upsert + ungated push) The June 2026 spam wave kept re-materializing after cleanup because two paths let an unauthenticated-by-captcha actor keep operating: 1. `update_trust_score` was an upsert. Any authenticated push/issue/PR from a DID with no agent row silently INSERTed one with a fresh `registered_at`, bypassing the iCaptcha gate on /api/register — so deregistered spam DIDs came back the moment they pushed. Make it an UPDATE-only no-op for unregistered DIDs; registration is the sole way into the agents table. Regression test added. 2. `git-receive-pack` had no rate limit. The per-DID limiter on the creation routes can't brake a push flood from a DID farm (one throwaway identity per repo), so the node absorbed several pushes/sec per IP, each a Tigris round-trip + peer-notify fan-out. Add a per- client-IP limiter (Fly-Client-IP / X-Forwarded-For) on the push path, default 600/h, `GITLAWB_PUSH_RATE_LIMIT` override, 0 disables. Fails open without a proxy header so self-hosted nodes aren't broken. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(node): address PR review — advertisement throttle, trusted-proxy IP key, bounded limiter Resolves the review findings on the push rate limiter: P1 — advertisement path bypassed the limiter. A push hits GET info/refs?service=git-receive-pack first, which calls acquire_fresh() (a Tigris download); it sat in git_read_routes under only optional_signature, so the flood was reachable via the cheap GET without ever sending the throttled POST. Now git_info_refs applies the same per-IP brake before the fresh acquire. P1 — client-supplied forwarded headers were trusted. The key came from Fly-Client-IP / the first X-Forwarded-For hop, both client-settable, so a flooder could rotate the header and never fill a bucket. Replaced with a TrustedProxy policy (GITLAWB_TRUSTED_PROXY = fly | x-forwarded-for | unset): trust only the operator's edge header, fall back to the real socket peer (ConnectInfo) otherwise. Fly config trusts Fly-Client-IP; the AWS/Caddy compose trusts the rightmost X-Forwarded-For hop. P2 — unbounded limiter key set / insert-before-reject. Added a max_keys cap (reject-before-insert, inline eviction when full) so a varied key cannot grow the map, and a rejected request never allocates. Applied to both the per-IP and per-DID limiters. P2 — malformed forwarded header disabled the brake. Empty leading XFF hop returned None and skipped the limiter; empty Fly-Client-IP returned Some(""). client_key now takes the rightmost XFF hop and always falls back to the peer, so a malformed header can never turn the limiter off. P2 — missing tests. Added: middleware 429 path, per-peer isolation, key-cap eviction/rejection, trusted-proxy header resolution, and an integration test asserting the receive-pack advertisement is throttled before the acquire. Also serve with into_make_service_with_connect_info so the peer address is available. New env: GITLAWB_PUSH_RATE_LIMIT, GITLAWB_TRUSTED_PROXY (documented in .env.example). Co-Authored-By: Claude Opus 4.8 <[email protected]> * Update infra/aws/compose.yaml.tftpl Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix(node): amortize the rate limiter's capacity eviction scan `RateLimiter::check` ran a full O(max_keys) `retain` sweep on every new-key miss once the map was at capacity — which is exactly the distinct-key-flood state the cap defends against, so the limiter serialized all traffic behind a 200k-entry scan per request under the mutex. Gate the inline sweep to run at most once per `sweep_interval` (min of the window and 1s), tracked by `last_sweep`. Between sweeps a new key is rejected without scanning; the background `cleanup()` loop still reclaims independently. The fast path for existing keys is unchanged, the key cap invariant holds (map never exceeds max_keys, rejected keys never allocate), and post-flood self-healing stays bounded to one interval. Added a test asserting a burst of capacity misses does not trigger repeated eviction scans. Co-Authored-By: Claude Opus 4.8 <[email protected]> --------- Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
gitlawb node on AWS (Terraform)
Single EC2 instance running the published node image + Postgres via Docker compose, with a persistent encrypted EBS volume, Elastic IP, SSM access, and daily snapshots.
Elastic IP ──► EC2 t4g.small (Amazon Linux 2023, arm64)
7545/tcp docker compose:
7546/udp ├─ node (ghcr.io/gitlawb/node, pulled — not built)
└─ postgres:16-alpine
EBS gp3 volume mounted at /mnt/data
├─ node/ → container /data (repos + identity key)
└─ postgres/ → postgres data dir
Prerequisites
- Terraform ≥ 1.6
- AWS credentials configured (
aws sts get-caller-identityworks) - AWS CLI + Session Manager plugin (for shell access)
- A default VPC in the target region (or pass
subnet_id)
Quick start
cd infra/aws
cp terraform.tfvars.example terraform.tfvars # edit: public_url at minimum
terraform init
terraform plan
terraform apply # ⚠ creates billable resources (~$25/mo: EC2 + EBS + snapshots)
After apply (~3-5 min for first boot to pull images and start):
curl "$(terraform output -raw api_url)/health"
⚠ First boot: back up the identity key
The node generates /data/keys/identity.pem on first start — it defines the
node's DID. Losing it permanently changes the node's identity. Back it up
immediately:
$(terraform output -raw ssm_session_command)
# in the session:
sudo cat /mnt/data/node/keys/identity.pem
Store the key somewhere safe (password manager / offline). The volume's
prevent_destroy guard and daily DLM snapshots protect against accidents, but
are not a substitute for an offline backup.
Shell access
SSM Session Manager — no SSH port, no keys to manage:
$(terraform output -raw ssm_session_command)
Bootstrap log: /var/log/gitlawb-bootstrap.log. Stack lives in /opt/gitlawb
(docker compose ps, docker compose logs node).
SSH is off by default; set ssh_ingress_cidr + ssh_key_name if you need it.
Building and pushing the image (ECR mode)
With create_ecr_repo = true the instance pulls from a private ECR repo in
this account instead of an external registry (the instance role gets pull
rights via the amazon-ecr-credential-helper). Build from the repo root —
arm64 to match t4g (native on Apple Silicon):
ECR_URL=$(terraform -chdir=infra/aws output -raw ecr_repository_url)
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin "${ECR_URL%%/*}"
docker build --platform linux/arm64 -t "$ECR_URL:latest" .
docker push "$ECR_URL:latest"
Then run the upgrade command (below) to roll it out.
Upgrading the node
User-data only runs at first boot, so upgrades go through SSM:
$(terraform output -raw upgrade_command)
This runs docker compose pull && docker compose up -d on the instance.
- With
image_tag = "latest"(default) that picks up the newest release. - With a pinned tag, first edit the tag in
/opt/gitlawb/compose.yamlon the instance (via SSM session), then run the upgrade command — and keepimage_tagin terraform.tfvars in sync so a future instance replacement boots the same version.
Replace the instance itself (OS/AMI/instance-type changes) with
terraform apply -replace=aws_instance.node — the data volume reattaches and
/data (including the identity key) survives.
Changing configuration
User-data only runs at first boot, and the instance ignores user_data drift
(ignore_changes), so editing terraform.tfvars values that feed the bootstrap
(bootstrap_peers, public_url, integrations, image_tag) does not
affect a running instance on terraform apply. To roll out such changes,
either edit /opt/gitlawb/.env on the instance (SSM session, then
docker compose up -d), or replace the instance:
terraform apply -replace=aws_instance.node
The data volume reattaches; repos, postgres data, and the identity key survive.
Remote state (optional)
Local state is the default. To move state to S3: create a versioned bucket,
uncomment the backend "s3" block in versions.tf, then:
terraform init -migrate-state
Teardown
terraform destroy will fail on the data volume by design
(prevent_destroy). To tear everything down:
- Back up the identity key (above) and take a final snapshot if you may return.
- Remove the
prevent_destroyline fromaws_ebs_volume.datainmain.tf. terraform destroy.
Note: DLM snapshots created by the policy are not deleted by destroy — clean them up in the EC2 console if unwanted. The Elastic IP is released on destroy.
Security notes
- Postgres password: generated by Terraform, stored as an SSM SecureString,
fetched at boot via the instance profile — never in user-data or state-free
files on disk (only in
/opt/gitlawb/.env, mode 600). It IS in Terraform state — treat state as sensitive (another reason for the S3 backend). - Sensitive optional vars (
operator_private_key,pinata_jwt,s3_access_key_id,s3_secret_access_key) follow the same SSM path. - SSM secrets use the AWS-managed
aws/ssmkey by default; setssm_kms_key_idto encrypt with a customer-managed KMS key instead (the instance role is grantedkms:Decrypton that key automatically). - IMDSv2 is required; metrics port is closed unless
metrics_ingress_cidris set. - The node itself serves plain HTTP on 7545. Set
domain_name(with DNS pointing at the Elastic IP) to run a Caddy sidecar with automatic Let's Encrypt TLS on 443 (+ http→https redirect on 80) — matching the Fly nodes. Certs persist on the data volume (/mnt/data/caddy).