Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b7e4409
feat(relay-gateway): add the crate with its config and route table
QuicksilverSlick Oct 27, 2026
c9848c6
fix(relay-gateway): cap the shutdown wait and keep tungstenite out of…
QuicksilverSlick Oct 27, 2026
9111346
feat(relay-gateway): gate /v1/link with a single-use NIP-98 bearer
QuicksilverSlick Oct 27, 2026
f63f986
fix(relay-gateway): keep claimed bearer ids 2 s past the relay's TTL
QuicksilverSlick Oct 27, 2026
5abb8ce
fix(relay-gateway): keep a bearer id while the window could still acc…
QuicksilverSlick Oct 27, 2026
948cdde
feat(relay-gateway): define the v1 link frames
QuicksilverSlick Oct 27, 2026
5fc3a0b
fix(relay-gateway): parse each frame type with only its own fields
QuicksilverSlick Oct 27, 2026
9a2eabb
feat(relay-gateway): run each link as a task with bounded output
QuicksilverSlick Oct 27, 2026
723226e
feat(relay-gateway): forward http frames to the relay byte for byte
QuicksilverSlick Oct 27, 2026
3b7159d
fix(relay-gateway): report an expired connect timeout as upstream_con…
QuicksilverSlick Oct 27, 2026
d31598b
feat(relay-gateway): hold NIP-42 relay sockets for the Worker
QuicksilverSlick Oct 27, 2026
59f315e
feat(relay-gateway): package the gateway as a container image
QuicksilverSlick Oct 27, 2026
d56cc9e
fix(relay-gateway): keep a stalled relay write under the socket's limits
QuicksilverSlick Oct 27, 2026
6477e7a
ci(relay-gateway): run the gateway tests in just test-unit
QuicksilverSlick Oct 27, 2026
d700831
Merge main (#73's advisory fixes) into feat/relay-gateway
QuicksilverSlick Oct 29, 2026
d23f1a8
ci(relay-gateway): run the gateway tests from _ci-rust.yml, not the J…
QuicksilverSlick Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/_ci-rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ jobs:
tool: cargo-nextest@0.9.136
- name: Unit tests
run: just test-unit
# Relay gateway (Bishop's keyless relay forwarder): infra-free tests
# against a loopback mock relay and Worker. Kept out of the Justfile so
# gateway PRs do not select the desktop suite. nextest gives each test
# its own process, which tests/logs.rs needs for its global subscriber.
- name: Unit tests (buzz-relay-gateway)
run: cargo nextest run -p buzz-relay-gateway

server-cross-compile:
name: Server Cross-Compile
Expand Down
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ members = [
"crates/buzz-dev-mcp",
"crates/buzz-voice",
"crates/buzz-audio-client",
"crates/buzz-relay-gateway",
"crates/buzz-backend-kubernetes",
"crates/buzz-datastore-tracing",
"examples/countdown-bot",
Expand Down
28 changes: 28 additions & 0 deletions Dockerfile.relay-gateway
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.7
# Buzz relay gateway: a keyless forwarder between one Worker link and one Buzz relay.
# The build context is the repo root (cargo needs the workspace manifests and Cargo.lock):
# docker build -f Dockerfile.relay-gateway --platform linux/amd64 -t buzz-relay-gateway:dev .
# Base images pinned by digest (first build, 27 Oct 2026).
FROM --platform=linux/amd64 rust:1.95.0-alpine@sha256:606fd313a0f49743ee2a7bd49a0914bab7deedb12791f3a846a34a4711db7ed2 AS builder
# build-base for ring and aws-lc-sys (C); cmake in case aws-lc-sys picks its cmake builder.
RUN apk add --no-cache build-base cmake
WORKDIR /build
COPY . .
# The image already has 1.95.0; this skips the components rust-toolchain.toml would install.
ENV RUSTUP_TOOLCHAIN=1.95.0
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \
cargo build --release --locked -p buzz-relay-gateway --bin buzz-relay-gateway \
&& install -D -m 0755 target/release/buzz-relay-gateway /out/buzz-relay-gateway \
&& strip /out/buzz-relay-gateway

FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab
LABEL org.opencontainers.image.title="Buzz relay gateway" \
org.opencontainers.image.description="Keyless forwarder from a Worker link to one Buzz relay" \
org.opencontainers.image.licenses="Apache-2.0"
COPY --from=builder /out/buzz-relay-gateway /usr/local/bin/buzz-relay-gateway
ENV BUZZ_RELAY_GATEWAY_BIND_ADDR=0.0.0.0:8080 RUST_LOG=info
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/buzz-relay-gateway"]
42 changes: 42 additions & 0 deletions crates/buzz-relay-gateway/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[package]
name = "buzz-relay-gateway"
description = "Keyless forwarder between one Worker link and one Buzz relay"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true

[lib]
name = "buzz_relay_gateway"
path = "src/lib.rs"

[[bin]]
name = "buzz-relay-gateway"
path = "src/main.rs"

[dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
base64 = { workspace = true }
buzz-auth = { workspace = true }
futures-util = { workspace = true }
nostr = { workspace = true }
reqwest = { workspace = true }
# Explicit ring provider, installed in main(): a workspace-wide build unifies
# ring and aws-lc-rs, and rustls then panics on the first wss:// connect.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }

[dev-dependencies]
hex = { workspace = true }
sha2 = { workspace = true }
61 changes: 61 additions & 0 deletions crates/buzz-relay-gateway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Buzz relay gateway

`buzz-relay-gateway` is a keyless forwarder between one Worker link and one
Buzz relay. It exists because a Cloudflare Worker cannot reach a relay that
sits behind Cloudflare itself (error 526), while a Cloudflare Container can.
The Worker keeps every key and signs every event; the gateway only checks the
link bearer and moves bytes.

## Trust boundary

- The gateway holds no secret. Its environment is four public values.
- The Worker opens `GET /v1/link` with `Authorization: Nostr <base64 event>`:
a kind 27235 event signed by a dedicated gateway-auth key (never a relay
member), `u` = the configured link URL, `method` = `GET`, within ±60 s, with
one `nonce`. Each bearer works once, and a link lives at most one hour.
- The gateway authenticates the Worker by signature. The Worker authenticates
the gateway only by transport (the Durable Object to container channel, or
TLS on a VM).
- Only one relay origin is reachable, through an exact route table:
`POST /query`, `/events`, `/count`, `GET /` (NIP-11) and the WebSocket on
`/`. Request and response headers are allowlisted, redirects are not
followed, nothing is retried, and bodies are never re-encoded, so NIP-98
`payload` tags still verify at the relay.
- NIP-42 is transparent: the relay's `AUTH` challenge reaches the Worker as an
ordinary `ws_msg`, and the Worker signs the reply.
- Logs carry ids, status codes, close codes and timings. They never carry
bearers, bodies, relay frames or close-reason text.

The frames are in `src/frame.rs`, the route table in `src/policy.rs`, the
limits in `src/config.rs`, and the link close codes in `src/link.rs`.

## Environment

| Variable | Required | Value |
|---|---|---|
| `BUZZ_RELAY_GATEWAY_BIND_ADDR` | no | default `127.0.0.1:8080`; the image sets `0.0.0.0:8080` |
| `BUZZ_RELAY_GATEWAY_RELAY_URL` | yes | `wss://host[:port]`; `ws://` only on loopback |
| `BUZZ_RELAY_GATEWAY_LINK_URL` | yes | the `u` every link bearer must carry |
| `BUZZ_RELAY_GATEWAY_LINK_PUBKEY` | yes | the gateway-auth public key, 64 lowercase hex |
| `RUST_LOG` | no | `info`; `tungstenite` and `tokio_tungstenite` stay off at any level, since at trace they print whole frames |

`GET /healthz` answers `ok`, or 503 once shutdown has begun. SIGTERM or
Ctrl-C drains in-flight requests for up to 10 s, closes every relay socket,
then closes each link with 1012.

## Testing

```
cargo test -p buzz-relay-gateway
cargo clippy -p buzz-relay-gateway --all-targets -- -D warnings
```

The tests run offline against an in-process mock relay (HTTP routes and a
NIP-42 socket) and a mock Worker. CI runs them in the Unit Tests job (`.github/workflows/_ci-rust.yml`).

The image builds from the repo root. Pushing it is a deployment step, not a
build step:

```
docker build -f Dockerfile.relay-gateway --platform linux/amd64 -t buzz-relay-gateway:dev .
```
Loading
Loading