Compare commits

..
Author SHA1 Message Date
beardthelion f3acd33f01 test(git-remote): clear the insecure-HTTP override in the fetch harness
fetch_with_helper passes the test process environment through to the helper,
so an operator or CI exporting GITLAWB_ALLOW_INSECURE_HTTP decided the
transport policy for every test that runs through it. Reported by CodeRabbit
on the review of 12e68e54.

Confirmed by execution: with the variable exported,
real_git_fetch_refuses_a_remote_plaintext_node failed, because the helper
permitted the URL and the refusal never appeared on stderr. With the
env_remove in place the whole target passes with the variable still exported.

Cleared in the shared harness rather than the one test, so a later test
cannot inherit the same ambient policy. A test that wants the opt-in can pass
it through the extra_env argument.
2026-09-08 00:01:42 -05:00
beardthelion d58da1e6d2 fix(git-remote): bypass the proxy for a loopback node
The transport guard inspected the URL, but reqwest's default client decides
the actual destination, and it honours HTTP_PROXY / ALL_PROXY for a loopback
URL too. So a proxy variable pointing off-machine carried the signed
plaintext request to it, past a guard that had just allowed the URL as
local. Reported by Greptile on the review of 12e68e54.

Confirmed against the built binary before the change:

    reqwest::connect: proxy(http://10.0.0.36:9999/) intercepts 'http://127.0.0.1:7545/'
    hyper_util::client::legacy::connect::http: connecting to 10.0.0.36:9999

and after it:

    hyper_util::client::legacy::connect::http: connecting to 127.0.0.1:7545

build_http_client now takes bypass_proxy and calls no_proxy() when the node
is plaintext-and-local. A local node is never legitimately reached through a
proxy, so this refuses one outright rather than reimplementing NO_PROXY
matching, which is the kind of duplicated parsing that goes wrong quietly.
A TLS node keeps its proxy support, and a remote plaintext node is already
refused unless the operator opted in, in which case they accepted this.

The regression test drives a real git fetch against the loopback shim with
HTTP_PROXY, http_proxy and ALL_PROXY all pointed at a TEST-NET-1 black hole,
and requires it to succeed: only an unproxied request can. Verified
load-bearing by building the client with bypass_proxy hardcoded false, which
reddens it. Worth noting it reddens on the "did not finish" assertion rather
than the "must ignore the proxy" one, because a proxied loopback request
stalls to the harness timeout instead of failing fast.

#394 reached the same conclusion independently for the gl doctor probe,
which uses a dedicated no-proxy client for exactly this reason.
2026-09-07 22:53:22 -05:00
beardthelion 12e68e5463 fix(git-remote): refuse a plaintext node off this machine unless opted in
git-remote-gitlawb took GITLAWB_NODE verbatim and built every request URL
from it with no scheme check, so pointing it at a remote node over
http:// sent git data in cleartext. Verified against the built binary on
main: GITLAWB_NODE=http://10.0.0.36:7777 with a keypair loaded produced

    GET http://10.0.0.36:7777/z6Mkq.../probe-repo/info/refs?service=git-upload-pack
    hyper_util::client::legacy::connect::http: connecting to 10.0.0.36:7777

with no warning and no refusal.

RFC 9421 signs a request but does not encrypt it, so on a plaintext hop
the pack contents and the Signature header are both readable, and open
#253 records that a captured signature is replayable for around 600s and
is not bound to a target host. Plaintext is what turns that from a
compromised-endpoint problem into a passive-observer one.

The guard fails closed and keys on the parsed address, not a string
match. Url normalizes the scheme and host at parse time, so an uppercase
HTTP://, a trailing-dot FQDN, userinfo, and the WHATWG numeric spellings
(2130706433, 0x7f000001, [::ffff:7f00:1]) all still read as this machine.
Loopback plaintext is the documented local-alpha default, so a false
positive there would break every stock install.

GITLAWB_ALLOW_INSECURE_HTTP=1 is the escape hatch for a private LAN where
the operator has accepted the risk, and it is documented in --help. This
is a breaking change for anyone already running a remote node over
plaintext: they get an error naming the variable rather than a silent
downgrade.

Nine tests. Eight in main.rs cover the classifier over loopback, remote,
TLS, unparseable and adversarially-spelled inputs, the gate in both
directions including the opt-in, and that --help documents the variable.
The ninth drives a real git fetch through the built binary in
real_git_fetch.rs, because none of the other eight notice if the call in
main() is deleted, which is the regression that would silently restore
the cleartext hop. Three mutations verified load-bearing: neutering the
gate, breaking the localhost allowance, and deleting the call site.

Two things deliberately not in scope. gl reads the same GITLAWB_NODE and
signs against it, confirmed by execution, but its default is https and
the fix belongs in NodeClient::new across roughly forty call sites, so it
is its own change. And the check lives in the helper rather than
gitlawb-core because that crate is held to a dependency-purity job and
keeps `url` behind a feature; when #394 lands and moves resolution into a
shared resolve_transport_node, this folds into it.
2026-09-07 22:24:58 -05:00
19 changed files with 956 additions and 4411 deletions

No files matched your search

+15 -19
View File
@@ -8,9 +8,24 @@
# The audit job stays hard-fail: only the advisories explicitly listed below are
# suppressed, so any NEW advisory still breaks CI. Each entry below has no
# available upstream fix; everything fixable is fixed in Cargo.lock instead.
#
# Tracking: the hickory ignores are reachable accepted risk. A scheduled re-check
# (.github/workflows/audit-schedule.yml) runs without these ignores and fails if
# the lockfile moves off the pinned vulnerable versions while the ignores remain.
[advisories]
ignore = [
# hickory-proto 0.25.2 (pulled by libp2p-dns 0.44 -> hickory-resolver 0.25).
# REACHABLE: the node wraps its QUIC transport in the system DNS resolver
# (crates/gitlawb-node/src/p2p/mod.rs:238), so a malicious DNS response can
# trigger these DoS paths. No fix is available: the latest libp2p-dns on
# crates.io is 0.44 and pins hickory 0.25; the fix requires hickory >=0.26.1.
# REMOVE both once libp2p-dns ships a release using hickory 0.26 (verified
# 2026-06-21: cargo update -p hickory-proto locks 0 packages; still 0.25.2).
# Tracking: https://github.com/Gitlawb/node/issues/76
"RUSTSEC-2026-0118", # hickory NSEC3 closest-encloser unbounded loop
"RUSTSEC-2026-0119", # hickory O(n^2) name-compression CPU exhaustion
# rsa 0.9.10 (Marvin timing sidechannel). Present in Cargo.lock because sqlx
# resolves sqlx-mysql unconditionally, and sqlx-mysql depends on rsa. cargo
# audit scans the lockfile, so it flags rsa even though it is NOT linked into
@@ -20,23 +35,4 @@ ignore = [
# ever built with the `mysql` feature, or any other consumer of rsa enters
# the build, at which point it becomes a real reachable advisory.
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)
# crypto-bigint 0.5.5 (secret-dependent branches in operations meant
# to be constant-time). REACHABLE accepted risk, on two paths:
# - alloy-signer -> elliptic-curve 0.13 -> crypto-bigint 0.5: the k256
# PrivateKeySigner that signs transactions in `gl name`
# (crates/gl/src/name.rs) and the node operator
# (crates/gitlawb-node/src/operator.rs).
# - aws-sigv4 (`sigv4a` feature, crates/gitlawb-node/Cargo.toml) ->
# crypto-bigint 0.5 (directly and via p256 0.13): SigV4a key
# derivation, used only for requests to endpoints that require SigV4a
# (e.g. S3 Multi-Region Access Points).
# Exploiting it needs local timing observation of those signing operations.
# The fix is crypto-bigint >=0.7.6, and no compatible dependent uses it:
# every alloy-signer release (through 2.5.0) requires elliptic-curve ^0.13,
# and aws-sigv4 1.6.4 is the first release on crypto-bigint 0.7 (1.5.x -
# 1.6.3 still require ^0.5.4), but it needs Rust 1.94.1, above our 1.91 MSRV
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
]
+2 -6
View File
@@ -88,12 +88,8 @@ GITLAWB_P2P_PORT=7546
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
GITLAWB_BOOTSTRAP_PEERS=
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
# Require a peer write to prove its DID on every transport: RFC 9421 signatures
# on the announce/sync routes, and an Ed25519 payload signature on gossip
# ref-update events. Keep false during rolling upgrades so existing live nodes
# can still communicate. Upgrade every gossip-publishing peer to a build that
# signs events before setting this true, or their updates are dropped on
# arrival with no error visible on their side.
# Require signatures on peer announce/sync write routes. Keep false during
# rolling upgrades so existing live nodes can still communicate.
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# Require the authenticated pusher to be the repo owner on git-receive-pack.
-100
View File
@@ -1,100 +0,0 @@
name: CodeQL
# Advanced-setup replacement for CodeQL default setup. Default setup analyzes
# every detected language on every PR, including Swift (macos-app/), which
# autobuilds on a macOS runner for ~13 minutes per PR and has hung for 55 on
# main; that single job was the longest check on most PRs. Here a PR analyzes
# only the languages it touches (scripts/ci-surfaces.sh), while push to main
# and the weekly schedule analyze every language, so main's code-scanning
# baseline stays complete.
#
# Requires CodeQL default setup to be DISABLED (Settings -> Code security ->
# Code scanning): GitHub rejects advanced-setup uploads while it is enabled.
#
# A PR that skips a language may show "configuration not found" on the CodeQL
# check for that language; that is informational and expected.
on:
pull_request:
branches: [main]
push:
branches: [main]
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
name: changed languages
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
codeql: ${{ steps.surfaces.outputs.codeql }}
matrix: ${{ steps.surfaces.outputs.codeql_matrix }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
fetch-depth: 2
- name: Map changed paths to languages
id: surfaces
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
# -z: literal paths; plain --name-only C-quotes unusual names.
git diff -z --name-only --no-renames HEAD^1 HEAD > changed-paths
tr '\0' '\n' < changed-paths >&2
scripts/ci-surfaces.sh < changed-paths > surfaces.out
else
scripts/ci-surfaces.sh --all > surfaces.out
fi
cat surfaces.out
grep -E '^(codeql|codeql_matrix)=' surfaces.out >> "$GITHUB_OUTPUT"
analyze:
name: Analyze (${{ matrix.language }})
needs: changes
if: needs.changes.outputs.codeql == 'true'
runs-on: ${{ matrix.runner }}
# Normal runs take <=14 min (Swift autobuild is the longest); cap a hung
# autobuild instead of letting it hold a runner for an hour.
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
packages: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Autobuild
if: matrix.build-mode == 'autobuild'
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
+3 -98
View File
@@ -30,62 +30,8 @@ env:
RUST_BACKTRACE: 1
jobs:
# Decide which checks this event needs. On pull_request, each job below runs
# only when the PR touches the surface it validates, and the cargo test /
# release-build jobs are scoped to the touched crates plus their reverse
# dependencies (scripts/ci-surfaces.sh). Every other event (push to main,
# merge_group, the release flow's workflow_dispatch) runs the full suite, so
# main and the merge queue keep whole-workspace coverage and main-red-alert
# still sees a complete run. A skipped job reports as "skipped", not green.
changes:
name: changed surfaces
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
rust: ${{ steps.surfaces.outputs.rust }}
rust_packages: ${{ steps.surfaces.outputs.rust_packages }}
windows: ${{ steps.surfaces.outputs.windows }}
windows_packages: ${{ steps.surfaces.outputs.windows_packages }}
docker: ${{ steps.surfaces.outputs.docker }}
audit: ${{ steps.surfaces.outputs.audit }}
core_deps: ${{ steps.surfaces.outputs.core_deps }}
release_versioning: ${{ steps.surfaces.outputs.release_versioning }}
release_tag: ${{ steps.surfaces.outputs.release_tag }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
# The pull_request checkout is the test-merge commit; depth 2 brings
# its first parent (the base tip) so the diff is exactly this PR.
fetch-depth: 2
# Every job's gate comes from the mapper, so pin its mapping first; a
# failure here fails this job and the run is red, not silently narrowed.
- name: Test the surface mapper
run: scripts/test-ci-surfaces.sh
- name: Map changed paths to surfaces
id: surfaces
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
# -z: literal paths; plain --name-only C-quotes unusual names.
git diff -z --name-only --no-renames HEAD^1 HEAD > changed-paths
tr '\0' '\n' < changed-paths >&2
scripts/ci-surfaces.sh < changed-paths > surfaces.out
else
scripts/ci-surfaces.sh --all > surfaces.out
fi
cat surfaces.out
cat surfaces.out >> "$GITHUB_OUTPUT"
fmt-clippy:
name: fmt + clippy
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -111,8 +57,6 @@ jobs:
release-tag-validation:
name: release tag validation
needs: changes
if: needs.changes.outputs.release_tag == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -126,8 +70,6 @@ jobs:
test:
name: test (${{ matrix.toolchain }})
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
@@ -172,17 +114,10 @@ jobs:
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
# `--workspace`, or `-p <crate>...` for the crates a PR touches plus
# every crate that depends on them.
PACKAGES: ${{ needs.changes.outputs.rust_packages }}
run: |
# shellcheck disable=SC2086 # PACKAGES is a word list by design
cargo test --locked $PACKAGES
run: cargo test --locked --workspace
build-release:
name: build --release
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
@@ -202,20 +137,10 @@ jobs:
key: release
- name: cargo build --release
env:
PACKAGES: ${{ needs.changes.outputs.rust_packages }}
run: |
# shellcheck disable=SC2086 # PACKAGES is a word list by design
cargo build --locked --release $PACKAGES
run: cargo build --locked --release --workspace
audit:
name: cargo audit
# cargo audit reads only Cargo.lock (+ .cargo/audit.toml), so a PR that
# leaves both unchanged cannot change its verdict; a newly published
# advisory against main is caught by the push-to-main run and the weekly
# Scheduled Audit instead of turning every unrelated PR red.
needs: changes
if: needs.changes.outputs.audit == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -275,8 +200,6 @@ jobs:
msrv:
name: MSRV (Rust 1.91)
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -300,12 +223,6 @@ jobs:
docker-build:
name: Docker build smoke test
# Runs when the image's own inputs change: the Dockerfile, the manifests
# its dependency layer copies, the lockfile, bootstrap-peers.json. A
# source-only Rust change is already compiled by `build --release` (stable)
# and `MSRV` (1.91, the image's toolchain); push to main still runs this.
needs: changes
if: needs.changes.outputs.docker == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -322,8 +239,6 @@ jobs:
test-windows:
name: test (windows, non-blocking)
needs: changes
if: needs.changes.outputs.windows == 'true'
runs-on: windows-latest
timeout-minutes: 45
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
@@ -351,13 +266,7 @@ jobs:
key: windows
- name: cargo test (shipped Windows crates)
shell: bash
env:
# `-p gl -p git-remote-gitlawb`, or whichever of the two a PR affects.
PACKAGES: ${{ needs.changes.outputs.windows_packages }}
run: |
# shellcheck disable=SC2086 # PACKAGES is a word list by design
cargo test --locked $PACKAGES
run: cargo test --locked -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
@@ -367,8 +276,6 @@ jobs:
# cannot do. Regen instructions live in the allowlist header.
core-deps-purity:
name: gitlawb-core dependency purity
needs: changes
if: needs.changes.outputs.core_deps == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -401,8 +308,6 @@ jobs:
# not its author knew this gate existed.
release-versioning:
name: release versioning coverage
needs: changes
if: needs.changes.outputs.release_versioning == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
+1 -1
View File
@@ -40,5 +40,5 @@ When a node denies a request, surface the denial to the user. Never render a den
## Toolchain and CI reality
- MSRV is Rust 1.91 (`rust-version` in the workspace `Cargo.toml`). The MSRV CI job runs `cargo check --workspace --all-targets` on exactly 1.91, so check that an API you rely on is stable there; the full test suite runs on stable.
- The PR workflow's jobs are `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. On a PR, `scripts/ci-surfaces.sh` runs each job only when the PR touches its inputs, and scopes `cargo test` and the release build to the touched crates plus every crate that depends on them. Root `Cargo.toml`/`Cargo.lock`/`.cargo/` changes run the Rust jobs (fmt, clippy, tests, release build, MSRV) on the whole workspace; every other job still runs only when its own inputs change. Edits to the CI wiring itself (`pr-checks.yml`, `codeql.yml`, `scripts/ci-surfaces.sh`), push to `main`, and the merge queue run every job on the whole workspace. Edits to any other workflow run none of these jobs on a PR; CodeQL only analyzes them as Actions code. Run `cargo test --workspace` locally regardless. The same script limits CodeQL (`codeql.yml`) to the languages a PR touches. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
- The PR workflow runs these jobs on every PR: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
- Conventional commit titles (`feat:`, `fix:`, `docs:`, ...) are required by convention and drive releases, but no CI job checks them; a bad title fails review, not the pipeline.
Generated
+544 -923
View File
File diff suppressed because it is too large. Load diff
+8 -9
View File
@@ -188,7 +188,12 @@ export GITLAWB_NODE=http://localhost:7545
git clone gitlawb://did:key:z6Mk.../my-repo
```
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
For public-network use, make sure `GITLAWB_NODE` points to the node you want, over
`https://`. The helper defaults to localhost for local development, and plaintext
`http://` is allowed only to this machine: requests are signed but not encrypted, so
a cleartext hop to a remote node exposes the pack contents and the `Signature`
header. A remote `http://` node is refused, and `GITLAWB_ALLOW_INSECURE_HTTP=1`
overrides that for a trusted private network.
### Full lifecycle against an iCaptcha-enforcing node
@@ -369,12 +374,6 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
The flag is not HTTP-only. It also gates inbound gossip ref-update events on the libp2p mesh, which carry an Ed25519 payload signature rather than RFC 9421 headers because there are no HTTP headers to sign. Set to `true`, an unsigned gossip event is dropped. Set to `false`, an unsigned event is accepted with a warning during the rolling-upgrade window, and the write budget it consumes is charged to the forwarding peer rather than to the DID it claims, since an unsigned event's claimed DID is asserted and not proven. A present-but-invalid signature is refused in both modes; that is forgery, not an un-upgraded peer.
Because the flag now spans both transports, the rollout order matters in one direction: **upgrade every gossip-publishing peer to a build that signs events before you set this to `true`.** Enabling it while an old publisher is still live drops that publisher's ref-updates on arrival, and the publisher sees no error, because gossip has no response to carry one. Upgrading the HTTP peers alone is not sufficient.
There is a second precondition on gossip ingest that the flag does not control. A publisher's `node_did` must already exist in the receiving node's peers table, or the event is dropped as an unknown peer DID. This holds in both modes, signed and unsigned alike: a valid signature proves key possession, not membership. Rows reach that table over HTTP, either from `GITLAWB_BOOTSTRAP_PEERS` when this node contacts a bootstrap peer and records the DID it reports, or from a prior `POST /api/v1/peers/announce`. So a peer that only ever joined the libp2p mesh, with no HTTP announce and no bootstrap contact in either direction, will have its ref-updates dropped even with a good signature and the flag off. If gossip is silently not landing from a peer you can see on the mesh, check that its DID is in `GET /api/v1/peers` on the receiving side first.
---
## Configuration
@@ -398,8 +397,8 @@ Important node settings:
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
+240 -8
View File
@@ -66,6 +66,10 @@ fn main() -> Result<()> {
// v0.1: default to localhost. Override with GITLAWB_NODE env var.
let node_base =
std::env::var("GITLAWB_NODE").unwrap_or_else(|_| "http://127.0.0.1:7545".to_string());
check_transport_security(
&node_base,
std::env::var_os("GITLAWB_ALLOW_INSECURE_HTTP").is_some(),
)?;
let repo_base = format!("{}/{}/{}", node_base, short_owner, repo_name);
tracing::debug!("repo_base: {repo_base}");
@@ -78,6 +82,76 @@ fn main() -> Result<()> {
run_helper(&repo_base, keypair.as_ref())
}
/// Whether `url` is plaintext http, regardless of where it points.
fn is_http(url: &str) -> bool {
reqwest::Url::parse(url.trim())
.map(|u| u.scheme() == "http")
.unwrap_or(false)
}
/// Whether `url` would send git data off this machine in cleartext.
///
/// True only for `http://` to a non-loopback host. RFC 9421 signs the request
/// but does not encrypt it, so on a plaintext hop the pack contents and the
/// `Signature` header are both readable, and a captured signature is replayable
/// for its freshness window against any host.
///
/// Loopback is decided from the parsed address rather than a string match, so
/// `127.0.0.2`, `[::1]` and an IPv4-mapped `[::ffff:127.0.0.1]` are all
/// recognised as this machine. A value that does not parse, or that is not
/// http(s), is not this guard's business and returns false: it fails later with
/// its own error, and naming it a TLS problem would misdirect the reader.
fn is_insecure_remote(url: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(url.trim()) else {
return false;
};
if parsed.scheme() != "http" {
return false;
}
let Some(host) = parsed.host_str() else {
return false;
};
let host = host.trim_end_matches('.').to_ascii_lowercase();
if host == "localhost" {
return false;
}
// host_str() keeps the brackets on an IPv6 literal.
let bare = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
if ip.is_loopback() {
return false;
}
// An IPv4-mapped IPv6 literal hides a v4 loopback from is_loopback().
if let std::net::IpAddr::V6(v6) = ip {
if let Some(v4) = v6.to_ipv4_mapped() {
if v4.is_loopback() {
return false;
}
}
}
}
true
}
/// Refuse a cleartext hop off this machine unless the operator has opted in.
///
/// Fail closed: the alternative is signing a request and then handing it, and
/// the pack it carries, to anyone on the path. `GITLAWB_ALLOW_INSECURE_HTTP`
/// exists for a private LAN where the operator has decided that is acceptable.
fn check_transport_security(node_base: &str, allow_insecure: bool) -> Result<()> {
if allow_insecure || !is_insecure_remote(node_base) {
return Ok(());
}
bail!(
"refusing to send git data to {node_base} over plaintext http.\n\
Requests are signed but not encrypted, so the pack contents and the \
Signature header are readable by anyone on the path, and a captured \
signature can be replayed.\n\
Use https://, or set GITLAWB_ALLOW_INSECURE_HTTP=1 to accept the risk \
(for a trusted private network only)."
)
}
// ── CLI argument handling ──────────────────────────────────────────────────────
/// How the binary was invoked, derived from its CLI arguments.
@@ -125,6 +199,7 @@ fn help_text() -> String {
ENVIRONMENT:\n\
\x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\
\x20 GITLAWB_ALLOW_INSECURE_HTTP Permit plaintext http:// to a non-loopback node (unset by default)\n\
\x20 GITLAWB_LOG Log filter (default: warn)\n\
\n\
FLAGS:\n\
@@ -199,7 +274,8 @@ fn handle_connect<R: Read>(
other => bail!("unsupported git service: {other}"),
}
let client = build_http_client()?;
// A loopback node must never be proxied; see build_http_client.
let client = build_http_client(!is_insecure_remote(repo_base) && is_http(repo_base))?;
// ── Phase 1: ref advertisement (GET /info/refs?service=<service>) ─────────
//
@@ -335,8 +411,22 @@ const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// node named, and on a 307/308 the pack body went with them. Scope the follow to the
/// origin that issued the redirect AND to an identical request-target, which is the
/// same predicate `gl` uses.
fn build_http_client() -> Result<reqwest::blocking::Client> {
Ok(reqwest::blocking::Client::builder()
/// `bypass_proxy` must be true whenever the node is on this machine. reqwest's
/// default client honours `HTTP_PROXY` / `ALL_PROXY` for a loopback URL too, so a
/// proxy variable pointing off-machine turns an allowed local request into a
/// cleartext hop carrying the `Signature` header, straight past
/// `check_transport_security`. Verified: with `HTTP_PROXY` set, reqwest logged
/// `proxy(...) intercepts 'http://127.0.0.1:7545/'` and dialled the proxy. A
/// local node is never legitimately reached through a proxy, so this refuses one
/// rather than trying to reimplement `NO_PROXY` parsing.
fn build_http_client(bypass_proxy: bool) -> Result<reqwest::blocking::Client> {
let builder = reqwest::blocking::Client::builder();
let builder = if bypass_proxy {
builder.no_proxy()
} else {
builder
};
Ok(builder
.timeout(HTTP_TIMEOUT)
.redirect(reqwest::redirect::Policy::custom(same_origin_redirect))
.build()?)
@@ -829,6 +919,148 @@ fn resolve_key_path() -> std::path::PathBuf {
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod insecure_transport_tests {
use super::*;
/// Loopback in every form the transport can legitimately be pointed at.
/// Plaintext to this machine is the documented local-alpha default and must
/// keep working, so a false positive here breaks every stock install.
#[test]
fn loopback_http_is_allowed() {
for url in [
"http://127.0.0.1:7545",
"http://localhost:7545",
"http://[::1]:7545",
"http://127.0.0.2:7545",
"http://[::ffff:127.0.0.1]:7545",
"http://LocalHost:7545",
] {
assert!(
!is_insecure_remote(url),
"{url} is this machine; plaintext to it must stay allowed"
);
}
}
/// The case the guard exists for: cleartext git data leaving the machine.
#[test]
fn remote_http_is_refused() {
for url in [
"http://node.example.com:7545",
"http://10.0.0.36:7777",
"http://192.168.1.10",
"http://[2001:db8::1]:7545",
"http://8.8.8.8",
] {
assert!(
is_insecure_remote(url),
"{url} sends git data off-machine in cleartext and must be refused"
);
}
}
/// TLS is always fine, loopback or not, so the guard keys on the scheme and
/// not merely on the host being remote.
#[test]
fn https_is_always_allowed() {
for url in [
"https://node.gitlawb.com",
"https://10.0.0.36:7777",
"https://127.0.0.1:7545",
] {
assert!(!is_insecure_remote(url), "{url} is TLS and must be allowed");
}
}
/// Spellings that defeat a naive loopback check. `Url` normalizes the scheme
/// and the host at parse time (lowercasing, and the WHATWG numeric forms), so
/// these must all still read as this machine.
#[test]
fn loopback_spellings_are_normalized_not_string_matched() {
for url in [
"HTTP://127.0.0.1:7545",
"Http://LOCALHOST:7545",
"http://localhost.:7545",
"http://user:[email protected]:7545",
"http://2130706433:7545",
"http://0x7f000001:7545",
"http://[::ffff:7f00:1]:7545",
] {
assert!(
!is_insecure_remote(url),
"{url} resolves to this machine; plaintext to it must stay allowed"
);
}
}
/// The mirror of the above: an uppercase scheme must not become an escape
/// from the guard, and a remote host in any spelling is still remote.
#[test]
fn uppercase_scheme_does_not_escape_the_guard() {
for url in [
"HTTP://node.example.com:7545",
"Http://8.8.8.8",
"http://NODE.EXAMPLE.COM",
"http://user:[email protected]",
] {
assert!(
is_insecure_remote(url),
"{url} is a remote cleartext hop and must be refused"
);
}
}
/// The gate itself, both directions, including the opt-in escape hatch.
#[test]
fn gate_refuses_remote_plaintext_unless_opted_in() {
let err = check_transport_security("http://node.example.com:7545", false)
.expect_err("remote plaintext must be refused by default");
let msg = err.to_string();
assert!(
msg.contains("plaintext http"),
"the refusal must say why: {msg}"
);
assert!(
msg.contains("GITLAWB_ALLOW_INSECURE_HTTP"),
"the refusal must name the escape hatch: {msg}"
);
check_transport_security("http://node.example.com:7545", true)
.expect("the opt-in must permit the same URL");
check_transport_security("http://127.0.0.1:7545", false)
.expect("the local default must keep working with no opt-in");
check_transport_security("https://node.gitlawb.com", false)
.expect("TLS must need no opt-in");
}
/// The documented knob must appear in --help. A gate the operator cannot
/// discover reads as a broken transport rather than a deliberate refusal.
#[test]
fn help_documents_the_opt_in() {
assert!(help_text().contains("GITLAWB_ALLOW_INSECURE_HTTP"));
}
/// An unparseable or non-http value is not classified as insecure here: it
/// fails later with its own error, and reporting it as a TLS problem would
/// send the reader after the wrong thing.
#[test]
fn unparseable_or_other_scheme_is_not_this_guards_problem() {
for url in [
"",
" ",
"not a url",
"ftp://node.example.com",
"file:///tmp/x",
] {
assert!(
!is_insecure_remote(url),
"{url:?} is not a cleartext-http-to-remote case"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -929,7 +1161,7 @@ mod tests {
#[test]
fn signed_requests_do_not_follow_a_redirect_off_the_node_origin() {
let kp = Keypair::generate();
let client = build_http_client().unwrap();
let client = build_http_client(false).unwrap();
let mut elsewhere = mockito::Server::new();
let never = elsewhere
@@ -1031,7 +1263,7 @@ mod tests {
#[test]
fn a_same_origin_path_changing_redirect_is_refused() {
let kp = Keypair::generate();
let client = build_http_client().unwrap();
let client = build_http_client(false).unwrap();
let mut node = mockito::Server::new();
let bounce = node
@@ -1082,7 +1314,7 @@ mod tests {
#[test]
fn an_identical_target_redirect_is_still_followed_up_to_the_chain_bound() {
let kp = Keypair::generate();
let client = build_http_client().unwrap();
let client = build_http_client(false).unwrap();
let mut node = mockito::Server::new();
let loop_route = node
@@ -1243,7 +1475,7 @@ mod tests {
fn a_rewritten_target_never_receives_the_signature() {
let kp = Keypair::generate();
let expected_did = kp.did().to_string();
let client = build_http_client().unwrap();
let client = build_http_client(false).unwrap();
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
let mut node = mockito::Server::new();
@@ -1305,7 +1537,7 @@ mod tests {
fn a_direct_signed_advertisement_verifies_under_the_node_verifier() {
let kp = Keypair::generate();
let expected_did = kp.did().to_string();
let client = build_http_client().unwrap();
let client = build_http_client(false).unwrap();
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
let mut node = mockito::Server::new();
@@ -267,6 +267,15 @@ fn write_response(mut stream: TcpStream, status: &str, content_type: &str, body:
/// Run `git fetch` in `clone` through the helper, with a hard timeout so a
/// regression to the deadlock fails fast instead of hanging the suite.
fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Output) {
fetch_with_helper_env(clone, node_url, &[])
}
/// [`fetch_with_helper`] with extra environment for the helper process.
fn fetch_with_helper_env(
clone: &Path,
node_url: &str,
extra_env: &[(&str, &str)],
) -> (bool, std::process::Output) {
let helper_bin = PathBuf::from(env!("CARGO_BIN_EXE_git-remote-gitlawb"));
let helper_dir = helper_bin.parent().unwrap().to_path_buf();
let path_env = match std::env::var_os("PATH") {
@@ -289,7 +298,14 @@ fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Outpu
// machine/CI with git-l10n installed and LANG set to a translated locale.
.env("LC_ALL", "C")
.env("GITLAWB_NODE", node_url)
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-fetch");
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-fetch")
// The helper inherits this process's environment, so an operator or CI
// that exports the insecure-HTTP override would decide the transport
// policy for every test below. Clear it and let each test opt in.
.env_remove("GITLAWB_ALLOW_INSECURE_HTTP");
for (k, v) in extra_env {
cmd.env(k, v);
}
run_bounded(cmd, Duration::from_secs(30))
}
@@ -1242,3 +1258,71 @@ fn run_bounded_bounds_join_when_leader_exits_leaving_a_pipe_holder() {
held pipes do not stall the joins)"
);
}
/// The plaintext-transport gate, proven at its CALL SITE rather than in
/// isolation. `main.rs`'s unit tests cover `is_insecure_remote` and
/// `check_transport_security` as functions; neither notices if the call in
/// `main()` is deleted, which is the regression that would silently restore the
/// cleartext hop. Only driving the built binary binds the wiring.
///
/// `192.0.2.1` is TEST-NET-1 (RFC 5737): non-loopback, reserved for
/// documentation, and never routable, so the gate is what stops this and no
/// connection is attempted even if it regressed.
#[test]
fn real_git_fetch_refuses_a_remote_plaintext_node() {
// A shallow fixture is enough: the gate fires before any negotiation.
let repos = build_divergent_repos(1);
let (ok, out) = fetch_with_helper(&repos.clone, "http://192.0.2.1:7545");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!ok || !out.status.success(),
"the fetch must not succeed: {stderr}"
);
assert!(
stderr.contains("plaintext http"),
"the helper must refuse the cleartext hop and say so; stderr was: {stderr}"
);
assert!(
stderr.contains("GITLAWB_ALLOW_INSECURE_HTTP"),
"the refusal must name the escape hatch; stderr was: {stderr}"
);
}
/// A proxy variable must not divert a LOOPBACK node off this machine.
///
/// reqwest's default client honours `HTTP_PROXY` for a loopback URL, so before
/// the fix a proxy pointing off-machine carried the signed plaintext request to
/// it, past the transport guard, which only ever inspected the URL. Observed
/// directly: `proxy(http://...:9999/) intercepts 'http://127.0.0.1:7545/'`.
///
/// The proxy here is a black hole on TEST-NET-1, so if the bypass regresses this
/// fetch cannot succeed: it either hangs to the harness timeout or fails to
/// connect. Success is only possible when the request went straight to the shim.
#[test]
fn real_git_fetch_ignores_a_proxy_for_a_loopback_node() {
let repos = build_divergent_repos(1);
let (server, clone) = (repos.server.clone(), repos.clone.clone());
let shim = start_shim(server.clone(), ShimMode::Normal);
let (completed, out) = fetch_with_helper_env(
&clone,
&shim.base_url,
&[
("HTTP_PROXY", "http://192.0.2.1:9999"),
("http_proxy", "http://192.0.2.1:9999"),
("ALL_PROXY", "http://192.0.2.1:9999"),
],
);
assert!(
completed,
"the fetch did not finish; a proxied loopback request would stall here. stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success(),
"a loopback fetch must ignore the proxy variables. stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
+9 -9
View File
@@ -44,14 +44,14 @@ dirs-next = "2"
# withholds a canonically-equivalent path regardless of NFC/NFD form (#101).
unicode-normalization = "0.1"
reqwest = { workspace = true }
libp2p-core = "0.44"
libp2p-gossipsub = "0.50.1"
libp2p-identify = "0.48"
libp2p-identity = { version = "0.3", features = ["ed25519", "peerid", "rand"] }
libp2p-kad = "0.49"
libp2p-quic = { version = "0.14", features = ["tokio"] }
libp2p-swarm = { version = "0.48", features = ["macros", "tokio"] }
multiaddr = "0.19"
libp2p-core = "0.43"
libp2p-gossipsub = "0.49.4"
libp2p-identify = "0.47"
libp2p-identity = { version = "0.2", features = ["ed25519", "peerid", "rand"] }
libp2p-kad = "0.48"
libp2p-quic = { version = "0.13", features = ["tokio"] }
libp2p-swarm = { version = "0.47", features = ["macros", "tokio"] }
multiaddr = "0.18"
futures = "0.3"
aws-sdk-s3 = { version = "1", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-config = { version = "1", features = ["behavior-version-latest"] }
@@ -72,7 +72,7 @@ alloy = { version = "1", default-features = false, features = [
"network",
"rpc-types-eth",
] }
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
[dev-dependencies]
mockito = "1"
-14
View File
@@ -2758,11 +2758,6 @@ async fn post_receive_replication_tail(
if announce {
if let Some(p2p) = &p2p_handle {
p2p.publish_ref_update(crate::p2p::RefUpdateEvent {
// Named, not literal: the ingest gate compares `v`
// against this same constant, so a bump has to move
// both ends together rather than leaving the emitter
// on a version the gate no longer accepts.
v: crate::p2p::CURRENT_REF_UPDATE_VERSION,
node_did: node_did_str.clone(),
pusher_did: pusher_did_clone.clone(),
repo: repo_slug.clone(),
@@ -2773,15 +2768,6 @@ async fn post_receive_replication_tail(
timestamp: chrono::Utc::now().to_rfc3339(),
cert_id: None,
cid: cid.map(|s| s.to_string()),
// Left unsigned here on purpose. The swarm loop
// signs the event with the node keypair via
// `p2p::signed_publish_bytes` immediately before it
// publishes, and skips the publish outright if
// signing fails, so the wire always carries a
// signature even though this construction site does
// not. Setting one here would be signed over a
// payload the publisher has not finished building.
sig: None,
})
.await;
}
+1 -3
View File
@@ -67,9 +67,7 @@ pub struct Config {
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
pub bootstrap_peers: Vec<String>,
/// Require a peer write to prove its DID on any transport: RFC 9421
/// signatures on the peer announce/sync write routes, and a payload
/// signature on gossip ref-update events.
/// Require RFC 9421 signatures on peer announce/sync write routes.
/// Keep false during rolling upgrades so existing live nodes can still gossip.
#[arg(
long,
+9 -53
View File
@@ -2571,7 +2571,15 @@ impl Db {
// The read-then-write race is benign: if a row appears in between,
// this write becomes an UPDATE and the UnprovenRepoint guard below
// still refuses any http_url change.
PeerWriteAuthority::Unproven if !self.peer_exists(did).await.unwrap_or(false) => {
PeerWriteAuthority::Unproven
if !sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM peers WHERE did = $1)",
)
.bind(did)
.fetch_one(&self.pool)
.await
.unwrap_or(false) =>
{
match did.parse::<gitlawb_core::did::Did>() {
// Only reachable from the bootstrap announce-back in main.rs,
// which passes the contacted peer's raw JSON string; the
@@ -2701,21 +2709,6 @@ impl Db {
Ok(())
}
/// Whether a DID has a row in `peers`, by keyed lookup.
///
/// For callers that only need the membership answer. `list_peers` fetches
/// and materializes every row, so using it as a membership test on a hot
/// path (the gossip ingest gate) makes the cost of one event grow with the
/// size of the table.
pub async fn peer_exists(&self, did: &str) -> Result<bool> {
Ok(
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM peers WHERE did = $1)")
.bind(did)
.fetch_one(&self.pool)
.await?,
)
}
pub async fn list_peers(&self) -> Result<Vec<PeerRecord>> {
let rows = sqlx::query(
"SELECT did, http_url, last_seen, last_ping_ok, announced_at
@@ -7871,41 +7864,6 @@ mod peer_authority_tests {
db
}
/// `peer_exists` answers the membership question the gossip ingest gate
/// asks on every event, so both answers are pinned here: a registered DID
/// is true, and an unregistered one is false rather than an error or a
/// prefix match on a registered DID.
#[sqlx::test]
async fn peer_exists_answers_both_ways(pool: sqlx::PgPool) {
let db = db(pool).await;
let did = VICTIM_DID;
assert!(
!db.peer_exists(did).await.unwrap(),
"an empty peers table must answer false, not error"
);
db.upsert_peer(
did,
"https://peer.example.com",
PeerWriteAuthority::Proven(did),
)
.await
.unwrap();
assert!(db.peer_exists(did).await.unwrap());
assert!(
!db.peer_exists(OTHER_DID).await.unwrap(),
"a different DID must not match"
);
assert!(
!db.peer_exists(&VICTIM_DID[..VICTIM_DID.len() - 1])
.await
.unwrap(),
"the lookup is an equality test, not a prefix test"
);
}
/// The whole row, read back through `list_peers` rather than raw SQL, so a
/// case that claims "unchanged" is comparing every column a consumer sees.
async fn row(db: &Db, did: &str) -> Option<(String, String, Option<String>, bool, String)> {
@@ -8322,7 +8280,6 @@ mod peer_authority_tests {
/// | `a_legacy_row_can_still_refresh_its_liveness` (db/mod.rs) | test-only. Seeds a PRE-GATE row by raw SQL on purpose: `upsert_peer` cannot create one, since the gate it is testing refuses exactly that DID. The fixture models what a deployed table already holds |
/// | `gossip_ping_round_requires_two_failures_before_persisting_unreachable` (main.rs) | test-only fixture seed. Raw SQL because the test drives the readiness HYSTERESIS, which needs a row already at `last_ping_ok = TRUE` before the round runs; it never exercises the announce gate |
/// | `manual_ping_uses_readiness_without_mutating_federation_gate` (api/peers.rs) | test-only fixture seed, same shape and same reason: the row under test must pre-exist so the assertion is about what the ping does NOT rewrite |
/// | `seed_peer` (p2p/mod.rs) | test-only fixture seed. Raw SQL so the gossip ingest tests can drive the known-peer gate directly, including the did:web case `upsert_peer` would refuse |
///
/// And the `upsert_peer` CALL-SITE authority table, which the ledger above
/// structurally cannot hold, because the bootstrap site issues no SQL of its own
@@ -8420,7 +8377,6 @@ mod peers_table_writer_guard {
("prune_non_public_peers", 1),
("prune_self_peers", 1),
("seed_local_peer", 1),
("seed_peer", 1),
("upsert_peer", 2),
];
+2 -4
View File
@@ -115,7 +115,7 @@ async fn main() -> Result<()> {
}
// Load or generate the node's identity keypair
let keypair = Arc::new(load_or_create_keypair(&config)?);
let keypair = load_or_create_keypair(&config)?;
// Sealing key for the legacy-scan continuation tokens, DERIVED from the identity
// just loaded so it is the same key after a restart (see `derive_scan_token_key`).
let scan_token_key = AppState::derive_scan_token_key(&keypair);
@@ -265,8 +265,6 @@ async fn main() -> Result<()> {
Arc::clone(&db),
config.auto_sync,
shutdown_rx,
Arc::clone(&keypair),
config.require_signed_peer_writes,
)
.await
{
@@ -414,7 +412,7 @@ async fn main() -> Result<()> {
config: Arc::new(config.clone()),
db,
node_did: node_did.clone(),
node_keypair: keypair,
node_keypair: Arc::new(keypair),
p2p: p2p_handle,
http_client,
ref_update_tx,
-46
View File
@@ -11,8 +11,6 @@
//! `gitlawb_sync_queue_processed_total{status}`
//! * are webhooks reaching their endpoints? —
//! `gitlawb_webhook_deliveries_total{result}`
//! * is inbound gossip being admitted or shed, and for which reason?
//! `gitlawb_gossip_ingest_events_total{outcome}`
//! * how big are the packs we're sending and receiving? —
//! `gitlawb_pack_size_bytes`
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
@@ -51,7 +49,6 @@ static AUTH_SUCCESSES: OnceLock<IntCounterVec> = OnceLock::new();
static AUTH_FAILURES: OnceLock<IntCounterVec> = OnceLock::new();
static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
static GOSSIP_INGEST: OnceLock<IntCounterVec> = OnceLock::new();
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
@@ -171,21 +168,6 @@ fn init_inner(version: &str, node_did: &str) {
.set(webhook_deliveries)
.expect("set WEBHOOK_DELIVERIES once");
let gossip_ingest = IntCounterVec::new(
Opts::new(
"gitlawb_gossip_ingest_events_total",
"Total inbound gossip ref-update events and what the ingest path decided about each",
),
&["outcome"],
)
.expect("gitlawb_gossip_ingest_events_total definition");
registry
.register(Box::new(gossip_ingest.clone()))
.expect("register gitlawb_gossip_ingest_events_total");
GOSSIP_INGEST
.set(gossip_ingest)
.expect("set GOSSIP_INGEST once");
let pack_size = Histogram::with_opts(
HistogramOpts::new(
"gitlawb_pack_size_bytes",
@@ -288,33 +270,6 @@ pub fn record_webhook_delivery(result: &str) {
}
}
/// Record what the gossip ingest path decided about one inbound ref-update.
/// `outcome` ∈ {accepted, unsigned_admitted, write_failed, rejected,
/// source_rate_limited, author_rate_limited, unsigned_source_rate_limited}.
///
/// `accepted` is reserved for signature-verified events. An unsigned event that
/// survives the rolling-upgrade window is `unsigned_admitted`, so the
/// authenticated-admission rate is not silently padded by legacy compatibility
/// traffic; an operator can tell whether the fleet still relies on the
/// compatibility allowance.
///
/// The three shed reasons stay separate labels rather than one `rate_limited`
/// because they answer different operator questions: `source_rate_limited` is a
/// forwarding peer over the pre-parse brake, `unsigned_source_rate_limited` is
/// that same forwarder relaying unproven traffic, and `author_rate_limited`
/// names a principal a signature actually proved. Collapsing them would make a
/// flood of unauthenticated garbage indistinguishable from one registered peer
/// pushing too hard, which is exactly the distinction an alert needs.
///
/// The label is the variant, never the free-form reason string that
/// `Rejected`/`WriteFailed` carry: those are attacker-influenced and would blow
/// up the label cardinality of a process-wide registry.
pub fn record_gossip_ingest(outcome: &str) {
if let Some(c) = GOSSIP_INGEST.get() {
c.with_label_values(&[outcome]).inc();
}
}
/// Record a pack body size observation (bytes).
pub fn observe_pack_size(bytes: f64) {
if let Some(h) = PACK_SIZE.get() {
@@ -410,7 +365,6 @@ mod tests {
record_auth_failure("test/route", "test_reason");
record_sync_processed("done");
record_webhook_delivery("ok");
record_gossip_ingest("accepted");
observe_pack_size(1024.0);
set_peers_connected(0);
}
File diff suppressed because it is too large. Load diff
-17
View File
@@ -143,23 +143,6 @@ impl RateLimiter {
false
}
/// The configured key ceiling. Tests use it to assert that a limiter a
/// production factory builds is actually bounded, which flooding the real
/// ceiling in distinct keys would be far too slow to show.
#[cfg(test)]
pub fn max_keys(&self) -> usize {
self.max_keys
}
/// The configured per-key request cap. Tests use it to assert that a
/// limiter a production factory builds carries the cap it is documented
/// with; re-asserting the constant on its own cannot tell whether the
/// factory passed that constant or a different one.
#[cfg(test)]
pub fn max_requests(&self) -> usize {
self.max_requests
}
pub async fn cleanup(&self) {
let now = Instant::now();
let mut state = self.state.lock().await;
-221
View File
@@ -1,221 +0,0 @@
#!/usr/bin/env bash
#
# Map a pull request's changed paths to the CI surfaces they touch, so each
# check in pr-checks.yml and codeql.yml runs only when its inputs changed and
# Rust jobs test only the affected crates.
#
# Usage:
# git diff -z --name-only --no-renames BASE HEAD | scripts/ci-surfaces.sh
# scripts/ci-surfaces.sh --all # every surface, whole workspace
#
# Input is NUL-terminated paths (`git diff -z`). Without -z, git C-quotes any
# path with non-ASCII, tab, quote or newline characters ("crates/gl/\303\251.rs"),
# which would match no surface and skip every job; newline-delimited input
# also cannot carry a path that contains a newline. Input that does not end in
# NUL (e.g. a plain `git diff --name-only`) is rejected rather than misread.
# scripts/test-ci-surfaces.sh pins the mapping.
#
# Prints GITHUB_OUTPUT lines (key=value). Each surface runs only for the paths
# listed for it below, so a path no surface lists (docs, README, infra/, any
# other workflow) runs no PR Checks job. Within the Rust surface, a change this
# script cannot attribute to specific crates widens rather than narrows: a
# global Rust input or an unknown crate directory runs the Rust jobs on the
# whole workspace. The CI wiring itself (pr-checks.yml, codeql.yml, this
# script) runs every surface.
# Non-PR events (push to main, merge_group, workflow_dispatch, schedule) are
# expected to pass --all, so main and the merge queue keep full coverage.
#
# Runnable from anywhere in the repo.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
all=false
if [ "${1:-}" = "--all" ]; then
all=true
elif [ "$#" -ne 0 ]; then
echo "usage: $0 [--all] < NUL-terminated-changed-paths" >&2
exit 2
fi
changed=()
if [ "$all" = false ]; then
path=""
while IFS= read -r -d '' path; do
[ -n "$path" ] && changed+=("$path")
done
# read leaves any unterminated tail in $path.
if [ -n "$path" ]; then
echo "ci-surfaces.sh: input is not NUL-terminated; pipe \`git diff -z --name-only\`" >&2
exit 2
fi
fi
# Workspace crates, derived from crates/*/Cargo.toml so a new crate is picked
# up without editing this script. dir -> package name, and dir -> the crate
# dirs it depends on through `path = "../<dir>"` (normal, dev, or build deps).
declare -A pkg_of=() deps_of=()
for manifest in "$ROOT"/crates/*/Cargo.toml; do
dir="$(basename "$(dirname "$manifest")")"
pkg_of[$dir]="$(sed -n 's/^name *= *"\([^"]*\)".*/\1/p' "$manifest" | head -n1)"
deps_of[$dir]="$(sed -n 's#.*path *= *"\.\./\([^"/]*\)".*#\1#p' "$manifest" | tr '\n' ' ')"
done
# Files outside crates/ that a crate compiles in (include_str!/include_bytes!),
# path -> crate dir. A change to one is a change to that crate, so its tests
# run. Candidates (keep those that resolve outside crates/<dir>/):
# git grep -nE 'include_(str|bytes)!\("(\.\./){2,}' crates
declare -A embedded_in=(
[bootstrap-peers.json]=gitlawb-node
)
# Mark a crate dir as touched; a dir this checkout does not know (removed or
# renamed crate) widens the Rust jobs to the whole workspace.
touch_crate() {
if [ -n "${pkg_of[$1]:-}" ]; then
touched[$1]=1
else
full_rust=true
fi
}
full_rust=false
rust=false
docker=false
audit=false
core_deps=false
release_versioning=false
release_tag=false
cq_actions=false
cq_js=false
cq_rust=false
cq_swift=false
declare -A touched=()
for path in "${changed[@]}"; do
case "$path" in
# The CI wiring itself: validate everything it gates.
.github/workflows/pr-checks.yml | .github/workflows/codeql.yml | scripts/ci-surfaces.sh)
all=true ;;
# Workspace-wide Rust inputs.
Cargo.toml | Cargo.lock | .cargo/* | rust-toolchain | rust-toolchain.toml \
| rustfmt.toml | .rustfmt.toml | clippy.toml | .clippy.toml)
full_rust=true ;;
crates/*/*)
dir="${path#crates/}"
touch_crate "${dir%%/*}" ;;
*)
if [ -n "${embedded_in[$path]:-}" ]; then
touch_crate "${embedded_in[$path]}"
fi ;;
esac
case "$path" in
Dockerfile | .dockerignore | bootstrap-peers.json | Cargo.toml | Cargo.lock \
| .cargo/* | rust-toolchain | rust-toolchain.toml | crates/*/Cargo.toml)
docker=true ;;
esac
case "$path" in
Cargo.lock | .cargo/audit.toml) audit=true ;;
esac
case "$path" in
Cargo.toml | Cargo.lock | crates/gitlawb-core/Cargo.toml \
| ci/gitlawb-core-allowed-deps.txt | scripts/check-gitlawb-core-deps.sh)
core_deps=true ;;
esac
case "$path" in
Cargo.toml | crates/*/Cargo.toml | release-please-config.json \
| .release-please-manifest.json | scripts/check-release-versioning.sh)
release_versioning=true ;;
esac
case "$path" in
scripts/resolve-release-tag.sh | scripts/test-resolve-release-tag.sh)
release_tag=true ;;
esac
case "$path" in
.github/workflows/* | .github/actions/*) cq_actions=true ;;
esac
case "$path" in
npm/* | *.js | *.mjs | *.cjs | *.jsx | *.ts | *.mts | *.cts | *.tsx) cq_js=true ;;
esac
case "$path" in
macos-app/* | *.swift) cq_swift=true ;;
esac
done
if [ "$all" = true ]; then
full_rust=true
docker=true
audit=true
core_deps=true
release_versioning=true
release_tag=true
cq_actions=true
cq_js=true
cq_swift=true
fi
# Affected crates: the touched ones plus everything that depends on them,
# transitively, so a gitlawb-core change still tests every consumer.
declare -A affected=()
for dir in "${!touched[@]}"; do affected[$dir]=1; done
grew=true
while [ "$grew" = true ]; do
grew=false
for dir in "${!deps_of[@]}"; do
[ -n "${affected[$dir]:-}" ] && continue
for dep in ${deps_of[$dir]}; do
if [ -n "${affected[$dep]:-}" ]; then
affected[$dir]=1
grew=true
break
fi
done
done
done
if [ "$full_rust" = true ] || [ "${#affected[@]}" -gt 0 ]; then
rust=true
cq_rust=true
fi
rust_packages=""
windows_packages=""
if [ "$full_rust" = true ]; then
rust_packages="--workspace"
windows_packages="-p gl -p git-remote-gitlawb"
elif [ "$rust" = true ]; then
for dir in $(printf '%s\n' "${!affected[@]}" | sort); do
rust_packages+="${rust_packages:+ }-p ${pkg_of[$dir]}"
case "${pkg_of[$dir]}" in
gl | git-remote-gitlawb) windows_packages+="${windows_packages:+ }-p ${pkg_of[$dir]}" ;;
esac
done
fi
windows=false
[ -n "$windows_packages" ] && windows=true
languages=()
[ "$cq_actions" = true ] && languages+=('{"language":"actions","build-mode":"none","runner":"ubuntu-latest"}')
[ "$cq_js" = true ] && languages+=('{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"}')
[ "$cq_rust" = true ] && languages+=('{"language":"rust","build-mode":"none","runner":"ubuntu-latest"}')
[ "$cq_swift" = true ] && languages+=('{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}')
codeql_matrix="{\"include\":[$(IFS=,; echo "${languages[*]}")]}"
codeql=false
[ "${#languages[@]}" -gt 0 ] && codeql=true
cat <<EOF
rust=$rust
rust_packages=$rust_packages
windows=$windows
windows_packages=$windows_packages
docker=$docker
audit=$audit
core_deps=$core_deps
release_versioning=$release_versioning
release_tag=$release_tag
codeql=$codeql
codeql_matrix=$codeql_matrix
EOF
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env bash
# Fixture test for scripts/ci-surfaces.sh, which alone decides which PR Checks
# jobs and CodeQL languages run. Pins at least one must-not-skip case per
# surface row, the crate fan-out and widening rules, and paths that git would
# C-quote without -z. Run from pr-checks.yml before the mapper is used.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
mapper="$repo_root/scripts/ci-surfaces.sh"
failures=0
# map PATH... : run the mapper on the paths as `git diff -z` would emit them.
map() {
if [ "$#" -gt 0 ]; then printf '%s\0' "$@"; fi | "$mapper"
}
# check CASE OUTPUT KEY=VALUE... : each KEY must have exactly VALUE.
check() {
local name="$1" out="$2" want key
shift 2
for want in "$@"; do
key="${want%%=*}"
if ! grep -qxF -- "$want" <<<"$out"; then
printf 'FAIL %s: want %s, got %s\n' "$name" "$want" \
"$(grep -E "^${key}=" <<<"$out" || echo '<missing>')" >&2
failures=$((failures + 1))
fi
done
}
# has_packages CASE OUTPUT PKG... : rust_packages must include each -p PKG.
has_packages() {
local name="$1" out="$2" pkgs pkg
shift 2
pkgs=" $(sed -n 's/^rust_packages=//p' <<<"$out") "
for pkg in "$@"; do
if [[ "$pkgs" != *" -p $pkg "* ]]; then
printf 'FAIL %s: rust_packages lacks -p %s (got%s)\n' "$name" "$pkg" "$pkgs" >&2
failures=$((failures + 1))
fi
done
}
nothing=(rust=false rust_packages= windows=false windows_packages= docker=false
audit=false core_deps=false release_versioning=false release_tag=false
codeql=false 'codeql_matrix={"include":[]}')
everything=(rust=true rust_packages=--workspace windows=true
'windows_packages=-p gl -p git-remote-gitlawb' docker=true audit=true
core_deps=true release_versioning=true release_tag=true codeql=true)
all_languages='codeql_matrix={"include":[{"language":"actions","build-mode":"none","runner":"ubuntu-latest"},{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"},{"language":"rust","build-mode":"none","runner":"ubuntu-latest"},{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
rust_only='codeql_matrix={"include":[{"language":"rust","build-mode":"none","runner":"ubuntu-latest"}]}'
# Docs-only and empty input run nothing.
check "docs only" "$(map README.md docs/x.md infra/x.tf)" "${nothing[@]}"
check "empty input" "$(map)" "${nothing[@]}"
# A crate change tests that crate and its reverse deps, nothing else.
check "gl source" "$(map crates/gl/src/main.rs)" \
rust=true 'rust_packages=-p gl' windows=true 'windows_packages=-p gl' \
docker=false audit=false "$rust_only"
out="$(map crates/gitlawb-core/src/lib.rs)"
check "gitlawb-core source" "$out" rust=true windows=true \
'windows_packages=-p git-remote-gitlawb -p gl' "$rust_only"
has_packages "gitlawb-core fan-out" "$out" gitlawb-core icaptcha-client \
gitlawb-attest git-remote-gitlawb gl gitlawb-node
check "icaptcha-client source" "$(map crates/icaptcha-client/src/lib.rs)" \
'rust_packages=-p gl -p icaptcha-client' 'windows_packages=-p gl'
# Anything the Rust surface cannot attribute to known crates widens.
check "unknown crate dir" "$(map crates/no-such-crate/src/lib.rs)" \
rust=true rust_packages=--workspace docker=false
check "root Cargo.lock" "$(map Cargo.lock)" \
rust=true rust_packages=--workspace docker=true audit=true core_deps=true \
release_versioning=false
check "toolchain file" "$(map rust-toolchain.toml)" rust=true rust_packages=--workspace
# Files compiled into a crate from outside crates/.
check "bootstrap-peers.json" "$(map bootstrap-peers.json)" \
rust=true 'rust_packages=-p gitlawb-node' windows=false docker=true
# One must-not-skip trigger per non-Rust row.
check "Dockerfile" "$(map Dockerfile)" docker=true rust=false
check "crate manifest" "$(map crates/gl/Cargo.toml)" \
docker=true release_versioning=true 'rust_packages=-p gl'
check "audit config" "$(map .cargo/audit.toml)" audit=true rust_packages=--workspace
check "core allowlist" "$(map ci/gitlawb-core-allowed-deps.txt)" core_deps=true rust=false
check "release-please config" "$(map release-please-config.json)" \
release_versioning=true rust=false
check "release tag resolver" "$(map scripts/resolve-release-tag.sh)" release_tag=true rust=false
check "other workflow" "$(map .github/workflows/release.yml)" "${nothing[@]:0:9}" \
codeql=true 'codeql_matrix={"include":[{"language":"actions","build-mode":"none","runner":"ubuntu-latest"}]}'
check "npm" "$(map npm/package.json)" codeql=true \
'codeql_matrix={"include":[{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"}]}'
check "swift" "$(map macos-app/Sources/App.swift)" rust=false codeql=true \
'codeql_matrix={"include":[{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
# Editing the CI wiring runs every surface, exactly like --all.
for wiring in .github/workflows/pr-checks.yml .github/workflows/codeql.yml scripts/ci-surfaces.sh; do
check "wiring $wiring" "$(map "$wiring")" "${everything[@]}" "$all_languages"
done
check "--all" "$("$mapper" --all </dev/null)" "${everything[@]}" "$all_languages"
# Names git C-quotes without -z (non-ASCII, tab, quote, newline) still map.
for name in $'\xc3\xa9.rs' $'a\tb.rs' 'a"b.rs' $'a\nb.rs'; do
check "quoted name $(printf %q "$name")" "$(map "crates/gl/src/$name")" \
rust=true 'rust_packages=-p gl'
done
check "non-ASCII swift" "$(map $'macos-app/Sources/Caf\xc3\xa9.swift')" codeql=true \
'codeql_matrix={"include":[{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
# Newline-delimited input (git diff without -z) is rejected, not misread.
if printf 'crates/gl/src/main.rs\n' | "$mapper" >/dev/null 2>&1; then
echo "FAIL newline-delimited input: mapper accepted it" >&2
failures=$((failures + 1))
fi
if [ "$failures" -ne 0 ]; then
echo "$failures ci-surfaces mapping check(s) failed" >&2
exit 1
fi
echo "ci-surfaces mapping: all checks passed"