Skip to content

feat(relay-gateway): keyless relay gateway for Bishop (Phase 0) - #76

Merged
QuicksilverSlick merged 16 commits into
mainfrom
feat/relay-gateway
Oct 31, 2026
Merged

QuicksilverSlick merged 16 commits into
mainfrom
feat/relay-gateway

Conversation

@QuicksilverSlick

Copy link
Copy Markdown
Owner

This adds crates/buzz-relay-gateway, the relay gateway for Dreamforge Bishop (plan Phase 0, slice 0b).

Why it's needed: Bishop runs as a Cloudflare Worker. Cloudflare Workers can't reach Block's hosted relay (every request gets error 526), but a Cloudflare Container can. The gateway is a small binary that runs in that container and forwards Bishop's relay traffic.

It holds no key and no secret. The Worker keeps every key and signs every event. The gateway checks who is connecting and forwards bytes.

It is all new files: about 3,370 lines, about half of them tests. The only lockfile change is the crate's own entry, and it adds no new dependencies. Russell asked for it as one PR instead of 7 stacked ones (27 Oct). The commits are in reading order: config and routes, the link bearer, frames, the link loop, HTTP forwarding, relay sockets, packaging, then CI.

What it does

  • One link per Worker connection. It is a WebSocket on /v1/link, opened with a single-use NIP-98 bearer (kind 27235) signed by a dedicated gateway-auth key.
    • The gateway pins only that key's public half.
    • Each bearer works once. Its id is kept while either clock could still accept it.
    • A link lives at most 1 h, with a rotate frame 5 min before the end.
  • HTTP forwarding, byte for byte. It forwards POST /query, /events and /count, and GET / for relay info. Bodies travel base64 inside JSON frames, so NIP-98 payload hashes still match.
    • 429s and every other status pass through untouched.
    • The gateway itself never retries.
  • NIP-42 relay sockets for the Worker. The relay's AUTH challenge reaches the Worker as an ordinary message, and the Worker signs it and sends it back. The huddle audio path is reserved for Phase 2 and answers not_supported.
  • It can't be used as an open proxy:
    • one relay origin set by config;
    • an exact method and path table;
    • request and response header allowlists with a fixed User-Agent;
    • no redirects, no proxy environment, no decompression, no Host override.
  • Bounded:
    • fixed frame and body sizes;
    • 16 HTTP requests in flight and 8 sockets per link;
    • timeouts on connect, idle, silence, and on a stalled relay write.
    • A full queue closes that socket or the link; memory never grows.
    • Closing a link closes every socket it opened.
  • Logs carry ids, status codes and timings only. They never include Authorization values, bodies, event JSON or bearers. tungstenite and tokio-tungstenite are kept off at every RUST_LOG level, because at trace they print whole frames.
  • Packaging:
    • /healthz answers without auth;
    • SIGTERM drains with a 2 s cap;
    • Dockerfile.relay-gateway builds a static musl binary into distroless as a non-root user, with base images pinned by digest. The image is about 6 MB compressed.

Checked

  • cargo fmt --check, clippy -p buzz-relay-gateway --all-targets -D warnings, cargo test (41 tests) and cargo metadata --locked all pass. Each of the 7 parts also passed on its own before they were combined.
  • cargo-deny 0.19.0 with the root deny.toml: advisories, bans, licenses and sources all ok, with the same warnings as main.
  • Reviews: three adversarial reviews (security, protocol correctness, and fork fit plus the test harness) found 11 problems. All were fixed, each with a test that fails when the fix is reverted. The automated commit security review found two more: the replay window boundary, and a relay write that could stall. Both are fixed, with tests.
  • End to end against Block's real relay, with a throwaway member key and a harness acting as the Worker:
    • From this PC: all 8 steps passed in 2.2 s: link, relay info, a signed query, a posted message, NIP-42 sign-in, a subscription, a message arriving live, and close.
    • From Cloudflare (DFW): a Durable Object opened the link into this image running as a Cloudflare Container, and all 8 steps passed in 1.9 s. They passed again with the subscription held for 300 s. The container's environment held only three public values.
    • Cleanup: the test Worker, container and image are deleted. Everything the test key wrote to the relay is deleted, and a read-only query confirms nothing remains.
  • just test-unit now runs the crate's tests in CI (cargo nextest run -p buzz-relay-gateway). Workspace membership alone only buys clippy and check.

Not in this PR

  • The huddle audio bridge (Phase 2).
  • Hosting off Cloudflare. A VM needs TLS in front, plus the header-read timeout and connection cap written up in the design's §12. The Cloudflare container is reachable only from its own Durable Object.
  • The Worker side, which lives in the Bishop repo (Phase 1).

The design is in Desktop/Buzz/bishop-phase0/gateway-design.md on Russell's PC (§13 records what changed during the build).

Factory-Task: bishop-phase-0b (relay gateway)
Built-by: claude-code/claude-opus-5-5
Undo: revert this PR's merge commit. Nothing else uses the crate yet, and nothing is deployed from it.

🤖 Generated with Claude Code

QuicksilverSlick and others added 14 commits October 27, 2026 08:26
Bishop (a Cloudflare Worker) cannot reach Block's hosted relay directly:
Cloudflare-to-Cloudflare requests end in error 526, while a Cloudflare
Container gets through. buzz-relay-gateway is the keyless forwarder that
runs in that container. The Worker keeps every key and signs every event;
the gateway only forwards bytes. This first part is the skeleton.

- Config::from_env reads four public values (bind address, relay URL,
  link URL, gateway-auth public key) and never holds a secret. A missing
  or invalid variable names the variable, never its value.
- The relay URL must be wss://host[:port] with nothing else; ws:// is
  accepted only on loopback, for the offline tests.
- policy.rs is the anti-open-proxy table: exact method and path matches
  (POST /query, /events, /count; GET / for NIP-11; the socket on /), a
  closed request header set with no duplicates, and a response filter
  that keeps only content-type and retry-after.
- The binary serves /healthz, installs the ring provider, logs JSON, and
  handles SIGTERM itself (it is PID 1 in the image).

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… logs

Review fixes for part 1.

- axum::serve sets no hyper timer, so a client that stops mid-request
  holds hyper's graceful shutdown open for good, and SIGTERM never leads
  to an exit. serve() now returns 2 s after its work is done whatever the
  open connections are doing. tests/serve.rs leaves a request half sent
  and checks that serve returns.
- fmt().init() also bridges `log` records, and at trace tungstenite logs
  every frame it sends or receives in full. log_filter() builds the
  filter from RUST_LOG and always turns tungstenite and tokio_tungstenite
  off, so no RUST_LOG value prints link or relay frames.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Worker opens a link with Authorization: Nostr <base64 event>, a kind
27235 event signed by a dedicated gateway-auth key that is never a relay
member. buzz_auth::verify_nip98_event checks the kind, the signature, the
60 s window, one u (the configured link URL) and one method (GET); the
gateway then requires the pinned pubkey and exactly one nonce of 16 to
128 characters.

- Each bearer works once: its event id is claimed in an in-memory replay
  cache for the relay's 120 s TTL. Only a bearer that passed every other
  check uses a slot, and a full cache refuses new bearers with 503 rather
  than forgetting old ones.
- Refusals answer 401 {"error":"unauthorized"} and log only a reason code
  (missing, malformed, invalid, wrong_key, no_nonce, replayed), never the
  header or the verifier's text, which quotes bearer content.
- At most four links at once; the frame loop lands in a later part, so a
  good bearer gets 501 for now.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fix for part 2. The bearer window is inclusive and counted in
whole seconds, so a bearer dated 60 s ahead is valid for up to 121 s,
while the cache let its id go after exactly 120 s. Ids are now kept for
122 s, and the unit test checks that an id is still refused at 120 s.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ept it

The 122 s monotonic hold covers the inclusive whole-second window, but a
backward wall-clock step after the claim could make the verifier accept a
bearer the cache had already forgotten. Each entry now also stays until the
wall clock is past its created_at plus the window.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every frame is one JSON object in a WebSocket text frame, with a type
field. Worker to gateway: http, ws_open, ws_send, ws_close, ping, pong.
Gateway to Worker: hello, http_res, ws_opened, ws_msg, ws_closed, error,
rotate, ping, pong. Bodies travel as standard base64 so they arrive byte
for byte, and relay socket text travels as a JSON string, sent as is.

- Inbound frames parse through one flat struct, because serde_json's
  RawValue does not survive internally tagged enums. That keeps the raw
  headers object, so the policy still sees a duplicated key.
- An unknown type maps to Unknown (the link stays open), even when it
  reuses a known field name with another type. A known type with a
  missing or mistyped field is BadFrame.
- Request and socket ids are 1 to 64 of [A-Za-z0-9_-], so they are safe
  to log.
- The frame types that carry headers, bodies or relay text do not derive
  Debug, so they cannot reach a log line by accident.
- Every error code has a fixed message that never quotes frame content.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fix for part 3. Inbound::parse read every frame into one flat
struct, so each field name had one type for all frame types. A ping
carrying "code":"later" (a ws_close field) failed to parse, got
bad_frame, and closed the link with 1008, although the protocol says
unknown fields are ignored and new optional fields stay v1.

parse() now reads `type` first, then deserializes the text as that type
alone: HttpFrame for http (RawValue needs a plain struct), and a
serde-tagged Inbound for the rest, with #[serde(other)] for unknown
types. A duplicated top-level key is still bad_frame. The unit tests
gain foreign-field cases for ping, pong, ws_send and http.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A good bearer now upgrades to the link WebSocket. The link sends hello
(version 1, link_id, expiry and the limits), answers ping with pong, and
pings the Worker every 20 s. HTTP and relay socket frames answer
not_supported until the next parts land.

- Lifetime: rotate 5 minutes before the hour, then close 4001, so the
  Worker's reconnect path runs every hour and one bearer never lasts long.
- Idle: no Worker frame for 60 s closes 4002.
- Backpressure: frames for the Worker go into a queue bounded at 1,024
  frames and 16 MiB of JSON, with try_send only. Nothing waits on it; when
  it fills, the Worker is not reading and the link closes 4003.
- Protocol: a binary frame closes 1003 (reserved for the Phase 2 audio
  bridge), a malformed frame gets bad_frame then 1008, a frame over 2 MiB
  closes 1009, and an unknown type gets unknown_type with the link open.
- Shutdown: /v1/link and /healthz answer 503, links close 1012, and serve
  keeps accepting until every tracked link has closed.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An http frame now becomes exactly one request to the pinned relay origin,
on a path taken from the route table, never from frame text. The decoded
body_b64 bytes go out as the body and the response bytes come back
base64-encoded as they arrived, so a NIP-98 payload tag the Worker signed
still verifies at the relay.

- The client never follows redirects (a 3xx comes back as a status, its
  location dropped), ignores proxy env, never decompresses, and is
  https-only for a wss relay. It sets only user-agent; nothing can add
  X-Forwarded-*, X-Pubkey, cookies, Origin or Accept-Encoding, or move
  Host.
- No retries, ever: a retry would burn the NIP-98 event id. A 429 and its
  body pass through untouched.
- Limits: 16 requests in flight per link (busy beyond), a 1 MiB request
  body, a 4 MiB response read in chunks, 10 s to connect and 30 s per
  request (upstream_connect, upstream_timeout).
- Shutdown gives in-flight requests up to 10 s while new ones get
  shutting_down, then closes the link 1012.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nect

Review fix for part 5. reqwest marks an expired connect_timeout as a
timeout too, so a relay that never answered the TCP or TLS handshake was
reported as upstream_timeout. That code means a request may have reached
the relay; upstream_connect means it did not. A connect error is now
upstream_connect whatever its cause. The test for it lands in part 7,
which has room for it.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ws_open now opens one relay WebSocket on the pinned wss origin plus /,
with only a user-agent header; no Worker header is forwarded, because
NIP-42 runs in-band. The relay's ["AUTH", challenge] reaches the Worker as
an ordinary ws_msg, and the Worker's signed reply goes back with ws_send.
The gateway parses no Nostr frames: it only times the AUTH exchange by
prefix (auth_challenge_ms, auth_reply_ms), since the relay closes a socket
5 s after its challenge.

- Every socket reports exactly one ws_closed, with by (relay, gateway or
  worker), the close code, and http_status when the upgrade was refused.
- The gateway keeps reading every relay socket, so the relay's pings are
  answered at once. A relay frame over 1 MiB, 90 s of relay silence, or 64
  Worker frames the relay is not taking close that one socket.
- ws_send text over 512 KiB gets frame_too_large and the socket stays
  open, so one bad frame cannot drop every subscription on it.
- At most 8 sockets per link. The huddle audio path is reserved for the
  Phase 2 bridge and answers not_supported.
- No relay socket outlives its link: on any link end each socket closes
  (1001 on shutdown, after in-flight HTTP drains), reports ws_closed, and
  anything still running after 2 s is aborted. The gateway never reopens
  a socket or replays a REQ; reconnects are the Worker's job.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dockerfile.relay-gateway builds a static musl binary on Alpine and runs
it on distroless static as the nonroot user, on port 8080, which is what
a Cloudflare Container needs (linux/amd64). Both base images are pinned
by digest. The image env holds only the bind address and RUST_LOG (plus
the base image's PATH and SSL_CERT_FILE); the three relay and link values
come from the Durable Object's start() env, and none of them is secret.
The binary is PID 1, so it handles SIGTERM itself. The image is about
6 MB compressed.

tests/logs.rs runs one HTTP forward and one socket AUTH whose bearer,
body and frames all carry a marker, at TRACE level, and checks that the
captured log holds neither the marker nor "Nostr ". It is its own test
binary: tracing caches callsite interest process-wide, so a scoped
subscriber misses events while other tests run on other threads.

The README states the trust boundary, the environment and how to test.
The tests are not in the Justfile unit-test list yet, so CI does not run
them.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for parts 5 to 7. They sit here because parts 5 and 6 are
at the PR size limit.

- A Worker frame's write to the relay ran inside the socket loop's
  select! arm, so a relay that stopped reading stalled the whole task:
  the queue overflow, the silence timer and the link's end went unseen,
  and later ws_send frames were dropped with no ws_closed. The write now
  races those three, so the socket still closes with backpressure,
  relay_silent or link_closed.
- When the relay closed a socket, the task returned at once and dropped
  tungstenite's queued close reply. It now flushes it (1 s cap), so the
  relay gets its close echoed, as RFC 6455 5.5.1 asks.
- tests/http.rs checks that a relay that never finishes the TLS
  handshake gets upstream_connect (the part 5 fix).
- tests/logs.rs installs the global subscriber through log_filter("trace")
  as main.rs does, `log` bridge included, and checks that a dependency's
  `log` record arrives. Without the tungstenite pins it now fails.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workspace membership only buys clippy and check; the unit lane lists each
crate it tests. nextest gives each test its own process, which tests/logs.rs
needs for its global subscriber.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@QuicksilverSlick QuicksilverSlick added the lane:hold Needs the owner's read before any train (paid path, auth, migrations, or asked for). label Oct 27, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@QuicksilverSlick

Copy link
Copy Markdown
Owner Author

CI run 40797029187 on d700831 (main merged in for #73's advisory fixes) failed only in Desktop Smoke E2E shards 9 and 10:

This PR changes no desktop code. Those shards ran only because the merge brought #73's desktop/ change, and both specs passed on main (dce9610) and on train #78. Locally on this head: cargo-deny advisories ok for both workspaces, cargo metadata --locked ok, gateway tests 41/41. That is the second red run, so no new push: re-running the failed jobs once.

…ustfile

Editing the Justfile matches CI's desktop path filter, so this PR kept
pulling in the desktop e2e suite and its flakes. The Unit Tests job now
runs the same `cargo nextest run -p buzz-relay-gateway` as its own step,
and the PR no longer touches the Justfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
@QuicksilverSlick

Copy link
Copy Markdown
Owner Author

Moved the buzz-relay-gateway cargo nextest run line out of the Justfile into its own step in the Unit Tests job (.github/workflows/_ci-rust.yml); this PR no longer touches the Justfile (d23f1a8).
Why: a Justfile edit matches CI's desktop path filter, so the earlier red runs were desktop e2e flakes this crate never touched; the gateway tests still run in CI (41 pass locally with cargo test).

@QuicksilverSlick QuicksilverSlick added the lane:ready Builder is done: green, agent-reviewed, exercised. The steward puts it in the next train. label Oct 30, 2026
@QuicksilverSlick
QuicksilverSlick merged commit aea3955 into main Oct 31, 2026
68 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lane:hold Needs the owner's read before any train (paid path, auth, migrations, or asked for). lane:ready Builder is done: green, agent-reviewed, exercised. The steward puts it in the next train.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant