Files
2df6ff9d30 fix(node): close two spam-vector root causes (trust upsert + ungated push) (#152)
* 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>
2026-07-05 17:31:21 +08:00
..
2026-06-18 22:05:50 +08:00
2026-06-05 22:38:53 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00

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-identity works)
  • 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.yaml on the instance (via SSM session), then run the upgrade command — and keep image_tag in 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:

  1. Back up the identity key (above) and take a final snapshot if you may return.
  2. Remove the prevent_destroy line from aws_ebs_volume.data in main.tf.
  3. 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/ssm key by default; set ssm_kms_key_id to encrypt with a customer-managed KMS key instead (the instance role is granted kms:Decrypt on that key automatically).
  • IMDSv2 is required; metrics port is closed unless metrics_ingress_cidr is 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).