16 Commits
Author SHA1 Message Date
Kevin Codex 73fd7473af Merge pull request #330 from Gitlawb/fix/default-enforce-owner-push
fix(node)!: enforce owner-only push by default
2026-08-25 22:06:12 +08:00
Kevin Codex 21f8cb6135 Merge pull request #173 from Gitlawb/fix/issue-135-ipfs-cid-tree-gate
fix(node): gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak (#135)
2026-08-25 22:04:14 +08:00
Vasanthdev2004 de4d932609 docs(infra,macos): make the opt-out reachable and stop the AWS guide contradicting itself
All four are in the deployment half added last round; none is in the Rust.

The AWS guide told operators two different things
--------------------------------------------------
"Changing configuration" still said editing `image_tag` does not affect a running
instance and routed to `-replace`, while the upgrade section thirty lines above
now reinstalls the rendered compose, which carries `image_tag`. The same
paragraph told operators to edit `.env` and restart — which the new "Why the
upgrade rewrites the compose file" section explains is silently ineffective for
any key the installed compose does not already name.

Split by which file carries the change rather than deleted, because the
distinction is real: values rendered into `compose.yaml` (`image_tag`, ports,
`domain_name`, `db_host`, `icaptcha_*`) roll out with `apply` + `upgrade_command`;
values user-data writes into `.env` at first boot (`public_url`,
`bootstrap_peers`, integrations) do not propagate from tfvars at all and need an
edit on the instance or a replacement. The third case is the one that bites: a
setting the installed compose does not yet name is read from `.env` and dropped,
so it needs the upgrade first.

`--remove-orphans` stopped being inert
---------------------------------------
The flag is not new, but until the upgrade rewrote `compose.yaml` it reconciled
against the instance's own file, so the only orphans were hand-started
containers. It now reconciles against a fresh rendering whose service set is
conditional — `postgres` only when `db_host` is empty, `caddy` only when
`domain_name` is set. Setting `use_rds` or clearing `domain_name` and then
running what the README calls a pull-and-restart stops and removes the local
postgres or the TLS terminator. That is what those variables mean, but it happens
on the upgrade rather than on apply, so it is now stated next to the
Terraform-owned warning. Data survives either way; both bind-mount under
/mnt/data.

The macOS setting had no writer
--------------------------------
`enforceOwnerPush` was persisted, loaded, emitted into `.env`, and forwarded by
the generated compose — and nothing set it. `SettingsWindow` had no state and no
assignment, so the only way to reach the opt-out on that launch path was hand
editing config.json in Application Support, which no document mentions. Editing
`.env` does not work there either: `writeEnvFile()` rebuilds it from `Config` on
every start. It now has a Toggle beside "Sync repos from peers", following the
pattern already in that view.

Three lines the change made untrue
-----------------------------------
The handler test asserted "with no configuration at all" while setting
`enforce_owner_push` explicitly — the right thing for the test, the wrong claim
for the message. The cross-tenant docstring still described three distinct pusher
DIDs after A and B were given `n1`'s owner. And OSS-READINESS-AUDIT listed
unprotected branches not enforcing owner checks as a live blocker; owner checks
now cover every branch, and only the UCAN half of that sentence still stands.

Also dropped the `#[allow(dead_code)]` on `test_state_with`, redundant since the
same commit added its call site.
2026-08-15 11:47:36 +05:30
anandh8x 0b47e1fc08 docs: clarify visibility limits 2026-08-15 10:32:24 +05:30
Vasanthdev2004 cdbda19c29 fix(node)!: enforce owner-only push by default
A `did:key` signature is authentication, not authorization: the method is
self-certifying, so any party can generate a keypair, derive its DID and sign.
With `GITLAWB_ENFORCE_OWNER_PUSH` defaulting to `false`, `owner_push_rejection`
short-circuited and every `git-receive-pack` carrying any valid signature was
accepted — including pushes to a repository the signer does not own, and
including private ones, on every branch not explicitly protected.

The gate itself already worked when switched on. What was missing is that no
reachable default configuration switched it on, so a node started with nothing
configured accepted a push from anyone. That default is what this changes.

The argument now takes a value so there is a way back: `--enforce-owner-push
false` and `GITLAWB_ENFORCE_OWNER_PUSH=false` both disable it, while the bare
`--enforce-owner-push` form still means `true`. Without this the flag would be
presence-only (`ArgAction::SetTrue`), which with a `true` default would leave
operators no way to opt out during a rolling upgrade.

Three concurrency tests pushed as non-owners in order to reach the shedding
logic they actually assert on. They now push as the repo owner, so they keep
exercising the shipped default rather than opting out of the gate.

Docs updated in README.md, .env.example and docs/RUN-A-NODE.md, including the
delegated-key caveat below. README also carries a dated cutover for
`GITLAWB_REQUIRE_SIGNED_PEER_WRITES`, which stays `false` for now so live peers
can finish upgrading.

BREAKING CHANGE: `git-receive-pack` now rejects a push whose authenticated DID
is not the repo owner, returning 403 before any ref update is applied.
Delegated and CI keys count as non-owners: a UCAN `git/push` capability is
verified but not yet honored for authorization, so an agent pushing under its
own DID cannot push while this is on. Set `GITLAWB_ENFORCE_OWNER_PUSH=false`
during a rolling upgrade, or have automation push as the repo owner, until
scoped collaborator / UCAN-delegated push rights land.
2026-08-14 17:22:23 +05:30
beardthelion f4f0f4bff0 Merge remote-tracking branch 'origin/main' into fix/issue-135-ipfs-cid-tree-gate 2026-08-13 09:44:35 -05:00
Gravirei 9f340eac5e Merge upstream/main into fix/issue-210-stable-advisory-lock-key
Resolve conflicts: keep the SHA-256 advisory-lock key change alongside
upstream's connection-affine RepoWriteGuard rework and its F1/F3b/F4/U8
test suite; RUN-A-NODE.md keeps both the updated Public URL row and the
rolling-upgrade advisory-lock row.
2026-08-10 21:31:38 +06:00
beardthelion 8f3a52175a docs(node): correct the walk-cap and db-pool operator docs
Two doc drifts jatmn flagged on #173, both docs-only.

The /ipfs walk cap is min(internal ceiling, GITLAWB_IPFS_MAX_REPOS_WALKED),
where the internal ceiling is MAX_PIN_SOURCES + 1 = 17. config.rs, README, and
.env.example all described a max() floor instead, so an operator reading them
would think the default of 64 was the effective ceiling and that raising the
knob bought headroom. Neither is true. The runtime is not changed: two tests set
the knob to 1 and depend on the cap actually being 1, so wiring the documented
floor would make the knob inert and break them.

db_max_connections has defaulted to 48 for a while and four comments still said
20. Two of them did arithmetic on it. main.rs and repo_store.rs justified the
separate advisory-lock pool by "20 is below 32", which is now false and made the
separation look like a consequence of the defaults; it is structural, since a
push holds its connection for the whole receive-pack at any pool size. Also adds
a RUN-A-NODE troubleshooting entry for the boot failure an in-place upgrade hits
when it is still configured with the old pool size of 20.
2026-08-10 01:24:46 -05:00
mike 547f9aed10 fix(node): preserve legacy peer compatibility 2026-08-09 23:09:55 -04:00
Gravirei 89e10d0ff1 fix(repo_store): use SHA-256 for stable advisory-lock key (#210)
DefaultHasher is not frozen by the Rust standard and has shifted across
toolchain versions, so the same (owner_slug, repo_name) produced a
different i64 advisory-lock key on different builds and broke
cross-machine write-exclusion for the same repo.

Hash with SHA-256 instead and pin the result with a golden-value test,
plus a one-axis-at-a-time differential test so a regression that drops
either parameter is caught. Document the SHA-256 re-keying caveat on
acquire_write and in the RUN-A-NODE.md operator checklist (drain writes
or cut over single-node during a rolling upgrade).
2026-08-05 19:26:44 +06:00
beardthelionandt d00d89ae5b fix(node): gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes (#82) (#161)
* feat(node): add per-client-IP limiters + knobs for the peer-sync routes (#82)

Plumb two dedicated per-IP RateLimiters into config/state so the peer-sync
enqueue routes can be braked independently: sync_trigger_rate_limiter (tight,
GITLAWB_SYNC_TRIGGER_RATE_LIMIT, default 60/hr) and peer_write_rate_limiter
(generous, GITLAWB_PEER_WRITE_RATE_LIMIT, default 600/hr). Both are bounded and
swept by the periodic cleanup loop, keyed via the node-wide push_limiter_trust.
No route is wired to them yet; that lands next.

* fix(node): require a signature on /sync/trigger and rate-limit the peer-sync routes (#82)

/api/v1/sync/trigger was reachable unauthenticated in the default config and
drove an O(peers) outbound fan-out + per-repo sync_queue enqueue — an anonymous
amplification/DoS. The same enqueue_sink is reachable via /sync/notify, which
also accepts unsigned known-peer requests and had no rate limit.

- Move /sync/trigger into its own always-signed route group (add_auth_layers),
  independent of require_signed_peer_writes, and give it a tight per-IP brake.
- Add a separate, generous per-IP brake to the peer-write group
  (/peers/announce, /sync/notify); notify's signature behavior is unchanged
  (rolling-upgrade compat), it only gains the brake. Separate buckets so an
  unsigned notify flood can't drain the signed trigger caller's quota.
- Per-IP (not per-DID): a did:key farm self-registers, so a signature/per-DID
  gate does not cap cost (INV-10); the brake keys on the client IP before auth.

Adds an adversarial test matrix (unsigned->401 both modes, IP flood->429,
DID-agnostic brake, forged X-Forwarded-For can't bypass, notify flood->429,
distinct-IP not throttled, cross-route non-exhaustion, 0-disables-but-sig-holds,
announce still accepts unsigned).

* fix(gl): surface node denial on sync trigger and sanitize the error body (#82)

Now that the node gates /api/v1/sync/trigger behind a signature and rate limit,
'gl sync trigger' must not render a 401/429 as a fabricated '✓ sync triggered /
0 peers' success. Check resp.status() before parsing; on a non-2xx, extract the
node's message and bail. Strip C0/C1 control bytes from the node-supplied string
before printing (INV-6) — a hostile node could embed ANSI/OSC escapes in the
body. Adds mockito tests: 401/429 surface as errors (RED without the status
check), control bytes are stripped, and the 200 path is unchanged.

* fix(review): bound gl error-body read, strip bidi controls, genericize 429 message (#82)

Code-review follow-ups on the #82 change:
- gl sync trigger: read the node error body with a capped reader
  (read_body_capped) instead of resp.text(), so a hostile/broken node can't
  force an unbounded allocation to surface a denial (INV-6, read half).
- sanitize_node_msg: also strip Unicode bidi/format controls (U+202A-202E,
  U+2066-2069, LRM/RLM/ALM) — is_control() only covers the Cc category, but a
  right-to-left override can reorder the displayed error line.
- too_many_requests(): the shared per-IP 429 middleware now serves the peer-sync
  routes too, so drop the push-specific wording ('rate limit exceeded'); the
  push-specific 429 in the receive-pack handler is unchanged.
Adds tests for the bidi/length sanitize and the oversized-body path.

* test(#82): close reasoned-not-run gaps with load-bearing tests

Executes the branches that were previously only reasoned:
- read_body_capped byte-cap test (RED against resp.text() — 2MB body -> ≤cap):
  vets the bounded-read fix, not just the display cap.
- sync-route 429 body asserts 'rate limit exceeded' and NOT 'push' (RED before
  the message was genericized); also finishes genericizing the warn log, which
  the response-body change had missed.
- real RFC 9421 signature through the full router reaches the handler (200) in
  both config modes — the positive path through the actual gate, not a
  direct-mount with an injected DID.
- /peers/announce flood -> 429 (the peer_write brake covers announce, not just
  notify).
- trigger_counts extraction unit test (happy + malformed -> 0,0), closing the
  count-formatting gap in the 200 path.
No production behavior change beyond the warn-log wording.

* fix(#82): require an identity for sync trigger and fix the rollout docs

Address review feedback on the always-signed /sync/trigger route.

- gl sync trigger now requires a local identity via load_keypair_from_dir and
  fails locally with "identity not found" before issuing a request, instead of
  .ok() silently sending an unsigned request that can only 401 remotely. Add a
  test proving it fails before dialing the node.
- Split /sync/trigger out of the staged unsigned peer-write rollout in the
  README and the OSS-readiness audit; only /peers/announce and /sync/notify keep
  the rolling-upgrade unsigned behavior.
- Reorder the two peer-sync rate-limit vars in .env.example for dotenv-linter.
- Bump crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204), matching main, so cargo
  audit passes.

---------

Co-authored-by: t <t@t>
2026-07-08 08:44:55 +08:00
0a15e763d2 feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31) (#68)
* feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31)

A valid did:key signature on git-receive-pack is authentication, not
authorization: any party can generate a key, derive its DID, sign, and push to
an unprotected branch. Owner checks only ran on protected branches, so a
non-owner could write to unprotected branches of an arbitrary repo.

Add an opt-in flag GITLAWB_ENFORCE_OWNER_PUSH (default false, mirroring
GITLAWB_REQUIRE_SIGNED_PEER_WRITES) that requires the authenticated pusher to be
the repo owner on every branch. Default-off keeps live nodes unaffected.

Design follows the maintainer's review on #31:
- Authorize on the canonical AuthenticatedDid injected by require_signature, via
  Extension<AuthenticatedDid> on the handler — not a re-parse of the headers.
  The legacy branch-protection check is left as-is.
- Fail closed: an absent identity or a non-owner caller is rejected. The policy
  lives in a pure fn (owner_push_rejection) so it is unit-tested without a DB.
- The gate runs before branch protection, so one rejection yields one error body.
- Factor the duplicated owner-match into auth::is_repo_owner, reused by
  receive-pack, protect.rs, and visibility.rs (each keeps its own message).
  events.rs / peers.rs are left out — they aren't owner checks.
- Name the push gate by intent (caller_authorized_to_push) so the planned
  Phase 2 UCAN git/push capability is a pure addition, not a rewrite.

Tests: owner (full + short DID) allowed, non-owner rejected, missing-DID
rejected, flag-off legacy. Docs: .env.example + docs/RUN-A-NODE.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(node): address review on #68 — 403 status, finish owner-match consolidation

Per @beardthelion's review:

- Return 403 Forbidden (not 400) for authorization denials: the owner-push gate,
  the branch-protection denial, and the owner checks in protect.rs / visibility.rs.
  400 reads as a malformed request and some git/CI clients retry it.
- Finish the is_repo_owner consolidation: route the branch-protection check and
  the repos list filter through the helper. The branch-protection check now uses
  the verified AuthenticatedDid (auth.0) instead of re-parsing the headers.
- Drop the second identity source on the push path entirely: the post-push event
  recording also uses auth.0, so extract_did_from_auth (and the handler's
  HeaderMap param + import) are removed.
- Tests assert the Forbidden variant (so a future status regression fails loudly)
  and add direct coverage for is_repo_owner / caller_authorized_to_push.
- Docs: warn that enabling the flag blocks delegated and CI agents until the
  Phase 2 UCAN git/push capability is honored.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: FrozenRaspberry <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-24 11:03:28 +08:00
Kevin CodexandOpenClaude 9e6e832ec5 Infra reorg (#27)
* refactor: move fly.toml into infra/fly/

Deployment configs now live under infra/, organized per target.
Dockerfiles and docker-compose.yml intentionally stay at the repo
root (shared by CI, build scripts, and the macOS app).

Deploy with: fly deploy -c infra/fly/fly.toml

Co-Authored-By: OpenClaude <[email protected]>

* feat(infra): Terraform for single-EC2 AWS deployment

infra/aws/ provisions a production-leaning single-node deployment:
- t4g.small (AL2023 arm64) running the published ghcr.io/gitlawb/node
  image + postgres:16 via docker compose
- separate encrypted EBS data volume (prevent_destroy) with daily DLM
  snapshots; survives instance replacement
- postgres password via random_password -> SSM SecureString, fetched at
  boot by instance profile (never in user-data); optional secrets
  (operator key, Pinata JWT, S3 secret) follow the same path
- SSM Session Manager access (no SSH by default), IMDSv2 required,
  metrics port closed unless explicitly opened
- SSM command document for image upgrades (user-data runs once)

Verified: terraform fmt/init/validate clean; rendered compose passes
docker compose config; rendered user-data passes bash -n.

Co-Authored-By: OpenClaude <[email protected]>

* fix(infra): address CodeRabbit review feedback

- api_url output now honors the public_url override (local.public_url)
- disable xtrace around SSM secret fetch + .env write so secret values
  never reach the bootstrap log
- add retry/timeout flags to the docker compose download
- validate snapshot_retain_count (1-1000) at plan time
- add language tags to fenced tree blocks (MD040)

Co-Authored-By: OpenClaude <[email protected]>

* fix(infra): address PR #27 review feedback

- security group now derives its VPC from the selected subnet, so
  subnet_id overrides into non-default VPCs work
- s3_access_key_id moves to SSM SecureString (same path as the secret
  key) instead of being embedded in user-data and state
- ignore user_data drift on the instance: it only runs at first boot,
  so re-rendering caused pointless stop/starts; README documents the
  -replace workflow for config changes
- DLM snapshot targeting is stack-specific (Snapshot + Name tags), not
  any Snapshot=true volume in the account
- comment in fly.toml that it targets the shared test instance
- user-data compose up gains --remove-orphans (consistent with the
  upgrade SSM document)
- node service gets an HTTP /health healthcheck in compose

Co-Authored-By: OpenClaude <[email protected]>

* feat(infra): optional customer-managed KMS key for SSM secrets

Add ssm_kms_key_id (default null = AWS-managed aws/ssm key). When set,
all five SecureString parameters are encrypted with the CMK and the
instance role gains kms:Decrypt scoped to that key — without it the
boot-time secret fetch would fail.

Co-Authored-By: OpenClaude <[email protected]>

* fix(infra): scope CMK kms:Decrypt to this stack's SSM parameters

The grant on the customer-managed key was unconditioned, allowing the
instance role to decrypt any ciphertext under the same CMK. Restrict it
with kms:ViaService = ssm.<region>.amazonaws.com and
kms:EncryptionContext:PARAMETER_ARN limited to the stack's parameter
ARNs.

Co-Authored-By: OpenClaude <[email protected]>

---------

Co-authored-by: OpenClaude <[email protected]>
2026-06-05 22:38:53 +08:00
Vasanth T 0b056f3ad0 [codex] fix Dependabot Rust alerts (#15)
* fix(deps): resolve dependabot rust alerts

* docs: clarify dependabot validation
2026-05-28 16:44:05 +08:00
Vasanth T d366fc6b97 [codex] chore: add live-safe OSS readiness pass (#14)
* chore: add live-safe OSS readiness pass

* fix(node): verify optional peer signatures during rollout

* docs: rewrite README for node roadmap
2026-05-28 15:34:47 +08:00
Kevin CodexandOpenClaude 6b9c5278d4 chore: initial public release
Open-source release of the gitlawb node, CLI, and git remote helper.

- gitlawb-node: Axum HTTP server with git smart-HTTP and DID auth
- gl: command-line client (identity, repos, PRs, MCP server)
- git-remote-gitlawb: git remote helper for gitlawb:// URLs
- gitlawb-core: shared crypto primitives (DID, CID, HTTP sigs, UCAN)

Dual licensed under MIT or Apache-2.0.

Co-Authored-By: OpenClaude <[email protected]>
2026-04-29 16:34:27 +08:00