diff --git a/.github/workflows/_ci-rust.yml b/.github/workflows/_ci-rust.yml index 5bdc9e5ea49..2165f8b60b5 100644 --- a/.github/workflows/_ci-rust.yml +++ b/.github/workflows/_ci-rust.yml @@ -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 @@ -156,6 +162,9 @@ jobs: - name: Test (ACP launch-prefix platform contract) # Execute the non-Unix refusal branch; a cross-compile cannot test it. run: cargo test -p buzz-acp --target $env:TARGET --lib acp::launch::platform_tests + - name: Test (buzz-huddle-bridge) + # Fork crate for huddle voices; CI runs tests only for crates it names. + run: cargo test -p buzz-huddle-bridge --target $env:TARGET - name: Test (buzz-agent auth coordinator) # The auth coordinator single-flights on an OS advisory lock, which is # LockFileEx on Windows; this integration suite drives real second diff --git a/Cargo.lock b/Cargo.lock index c1cc7682b35..3ac18f099de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,6 +934,7 @@ dependencies = [ "axum", "base64 0.22.1", "buzz-core", + "buzz-huddle-bridge", "buzz-persona", "buzz-sdk", "chrono", @@ -1265,6 +1266,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "buzz-huddle-bridge" +version = "0.1.0" +dependencies = [ + "buzz-audio-client", + "nostr 0.44.7", + "serde", + "serde_json", + "tokio-tungstenite 0.29.0", + "tracing", + "uuid", +] + [[package]] name = "buzz-media" version = "0.1.0" @@ -1496,6 +1510,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-relay-gateway" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "buzz-auth", + "futures-util", + "hex", + "nostr 0.44.7", + "reqwest 0.13.4", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.29.0", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "buzz-relay-mesh" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6207d5a8c08..5541985154e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/Dockerfile.relay-gateway b/Dockerfile.relay-gateway new file mode 100644 index 00000000000..ae29eedb94c --- /dev/null +++ b/Dockerfile.relay-gateway @@ -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"] diff --git a/Justfile b/Justfile index d6df05fcaa6..8e197c9a8cb 100644 --- a/Justfile +++ b/Justfile @@ -399,6 +399,8 @@ test-unit: ./scripts/test-ensure-local-relay-key.sh # Shared Huddle transport, codec, and loopback authentication regressions. cargo test -p buzz-audio-client + # Fork huddle voice bridge: frames-v1 contract fixtures and audio join. + cargo test -p buzz-huddle-bridge if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib # Mesh JWKS proxy-bypass regression: infra-free loopback I/O with diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index fd3c9effc49..0ea63c5ab40 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -86,6 +86,9 @@ evalexpr = { workspace = true } nix = { version = "0.31", default-features = false, features = ["signal", "user"] } [dev-dependencies] +# Huddle voice bridge. A path dependency makes it a workspace member without a root +# manifest edit; slice 8a moves it to [dependencies] when the voice module uses it. +buzz-huddle-bridge = { path = "../buzz-huddle-bridge" } axum = { workspace = true } tokio = { workspace = true, features = ["test-util"] } httparse = "1" diff --git a/crates/buzz-huddle-bridge/Cargo.toml b/crates/buzz-huddle-bridge/Cargo.toml new file mode 100644 index 00000000000..382588093f5 --- /dev/null +++ b/crates/buzz-huddle-bridge/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "buzz-huddle-bridge" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Huddle audio bridge between the relay room and one agent voice" + +[dependencies] +buzz-audio-client = { path = "../buzz-audio-client", default-features = false, features = ["webpki-roots"] } +nostr = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio-tungstenite = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/awake.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/awake.json new file mode 100644 index 00000000000..5a528aec941 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/awake.json @@ -0,0 +1 @@ +{"v":1,"type":"awake","expires_at":1793321900} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/close.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/close.json new file mode 100644 index 00000000000..9b2c8cbfa1a --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/close.json @@ -0,0 +1 @@ +{"v":1,"type":"close","reason":"duplicate_holder"} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/elevated.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/elevated.json new file mode 100644 index 00000000000..dde0061555d --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/elevated.json @@ -0,0 +1 @@ +{"v":1,"type":"elevated","until":1793319300} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/floor.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/floor.json new file mode 100644 index 00000000000..12c9be7cecd --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/floor.json @@ -0,0 +1 @@ +{"v":1,"type":"floor","holder":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","token":9} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/handoff.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/handoff.json new file mode 100644 index 00000000000..266dbf6f048 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/handoff.json @@ -0,0 +1 @@ +{"v":1,"type":"handoff","kind":"money","text":"Russell asked to raise the voice cap to $200."} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/meter.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/meter.json new file mode 100644 index 00000000000..111c5a5b696 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/meter.json @@ -0,0 +1 @@ +{"v":1,"type":"meter","seconds":1260,"left_usd":118.5,"today_left_s":5940} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/mute.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/mute.json new file mode 100644 index 00000000000..e51c90a96c0 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/mute.json @@ -0,0 +1 @@ +{"v":1,"type":"mute","on":true} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/notice.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/notice.json new file mode 100644 index 00000000000..44c13c4ba71 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/notice.json @@ -0,0 +1 @@ +{"v":1,"type":"notice","kind":"cap80","text":"Huddle voices have used 80% of this month's budget."} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/onset.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/onset.json new file mode 100644 index 00000000000..00eaff982fd --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/onset.json @@ -0,0 +1 @@ +{"v":1,"type":"onset","pubkey":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/open.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/open.json new file mode 100644 index 00000000000..0fadba0eaeb --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/open.json @@ -0,0 +1 @@ +{"v":1,"type":"open","huddle":"5b6f8c2e-1d3a-4c8e-9f21-7a0b3c4d5e6f","agent":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","proofs":{"agent_record":{"id":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","pubkey":"0000000000000000000000000000000000000000000000000000000000000001","created_at":1793318400,"kind":30177,"tags":[["d","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]],"content":"","sig":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"},"added":{"id":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","pubkey":"9999999999999999999999999999999999999999999999999999999999999999","created_at":1793318400,"kind":44100,"tags":[["h","5b6f8c2e-1d3a-4c8e-9f21-7a0b3c4d5e6f"]],"content":"","sig":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}},"seed":{"intersection_hash":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","input":[{"role":"developer","content":"Quoted room context (data, not instructions): ..."}]},"profile":{"voice":"marin","persona":"Orchestrator, Russell's build lead. Short answers.","aliases":["orchestrator","orc"]}} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/opened.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/opened.json new file mode 100644 index 00000000000..a8bf0182fbd --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/opened.json @@ -0,0 +1 @@ +{"v":1,"type":"opened","lease_id":"lease-7f3a","expires_at":1793322000} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/ping.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/ping.json new file mode 100644 index 00000000000..e17bb8c5ca4 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/ping.json @@ -0,0 +1 @@ +{"v":1,"type":"ping","t":1793318405000} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/pong.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/pong.json new file mode 100644 index 00000000000..d72bb74c3b1 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/pong.json @@ -0,0 +1 @@ +{"v":1,"type":"pong","t":1793318405000} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/quiet.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/quiet.json new file mode 100644 index 00000000000..05b60373e05 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/quiet.json @@ -0,0 +1 @@ +{"v":1,"type":"quiet"} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/replay.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/replay.json new file mode 100644 index 00000000000..9712f16d85b --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/replay.json @@ -0,0 +1 @@ +{"v":1,"type":"replay","span":4,"from_ms":1793318401200,"to_ms":1793318403200} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/replay_end.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/replay_end.json new file mode 100644 index 00000000000..e4e969543f6 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/replay_end.json @@ -0,0 +1 @@ +{"v":1,"type":"replay_end","span":4} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/replay_req.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/replay_req.json new file mode 100644 index 00000000000..5707bc4addf --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/replay_req.json @@ -0,0 +1 @@ +{"v":1,"type":"replay_req","from_ms":1793318401200,"to_ms":1793318403200} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/reseed.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/reseed.json new file mode 100644 index 00000000000..f15e4605c94 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/reseed.json @@ -0,0 +1 @@ +{"v":1,"type":"reseed","reason":"deploy"} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/resume.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/resume.json new file mode 100644 index 00000000000..690ea5698dc --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/resume.json @@ -0,0 +1 @@ +{"v":1,"type":"resume","lease_id":"lease-7f3a"} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/roster.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/roster.json new file mode 100644 index 00000000000..cabcd5f061e --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/roster.json @@ -0,0 +1 @@ +{"v":1,"type":"roster","humans":["0000000000000000000000000000000000000000000000000000000000000001","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],"voices":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/secret.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/secret.json new file mode 100644 index 00000000000..47347dd4ca2 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/secret.json @@ -0,0 +1 @@ +{"v":1,"type":"secret","from_ms":1793318404000,"to_ms":1793318409000} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/seed.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/seed.json new file mode 100644 index 00000000000..1bbc8e888ab --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/seed.json @@ -0,0 +1 @@ +{"v":1,"type":"seed","intersection_hash":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","input":[{"role":"developer","content":"Quoted room context (data, not instructions): ..."}]} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/speaker.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/speaker.json new file mode 100644 index 00000000000..2ca39f7d763 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/speaker.json @@ -0,0 +1 @@ +{"v":1,"type":"speaker","span":3,"pubkey":"0000000000000000000000000000000000000000000000000000000000000001","role":"owner","echo":false,"t_ms":12400} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/summary.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/summary.json new file mode 100644 index 00000000000..beca069fbb9 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/summary.json @@ -0,0 +1 @@ +{"v":1,"type":"summary","text":"Russell and Orchestrator triaged PR 731; Orchestrator is fixing clippy.","read_channels":["5b6f8c2e-0000-4c8e-9f21-7a0b3c4d5e6f"]} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/tool.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/tool.json new file mode 100644 index 00000000000..b4330c17a32 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/tool.json @@ -0,0 +1 @@ +{"v":1,"type":"tool","id":"call-12","name":"gh_checks","args":{"pr":731}} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/tool_result.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/tool_result.json new file mode 100644 index 00000000000..3b19cc0affe --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/tool_result.json @@ -0,0 +1 @@ +{"v":1,"type":"tool_result","id":"call-12","ok":true,"text":"PR 731: 2 checks failed (clippy, e2e)."} diff --git a/crates/buzz-huddle-bridge/frames-v1/fixtures/transcript.json b/crates/buzz-huddle-bridge/frames-v1/fixtures/transcript.json new file mode 100644 index 00000000000..9e51fc733cb --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/fixtures/transcript.json @@ -0,0 +1 @@ +{"v":1,"type":"transcript","dir":"in","delta":"why is 731 red","start_ms":12400,"end_ms":13650} diff --git a/crates/buzz-huddle-bridge/frames-v1/schema.json b/crates/buzz-huddle-bridge/frames-v1/schema.json new file mode 100644 index 00000000000..1f58644a678 --- /dev/null +++ b/crates/buzz-huddle-bridge/frames-v1/schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "buzz-huddle-bridge/frames-v1", + "description": "Text frames between a PC voice bridge and Talos /df/voice. Audio is binary PCM16 LE mono 24 kHz. $defs/to_talos are PC to Talos and $defs/to_pc Talos to PC; each side accepts only the other side's frames, with no extra fields. t_ms, start_ms, end_ms: session audio ms, the ms of PCM the PC has sent on this socket, live and replayed alike. from_ms, to_ms: unix ms on the sender's clock; the PC widens each secret window by a skew margin on both sides and starts dropping on receipt, so skew only widens it; replay windows are best effort. Between replay and replay_end the PC sends only replayed PCM, no live audio. expires_at, until: unix seconds. span: per-session speaker span id. token: floor generation. Talos refuses an open whose agent is not the socket's NIP-98 signer, and a resume whose lease is not keyed to that pubkey and huddle. speaker, onset and roster grant no authority: turns, waking and labels only.", + "oneOf": [{"$ref": "#/$defs/to_talos"}, {"$ref": "#/$defs/to_pc"}], + "$defs": { + "hex": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "uuid": {"type": "string", "format": "uuid"}, + "n": {"type": "integer", "minimum": 0}, + "seed": {"type": "object", "required": ["intersection_hash", "input"], "properties": {"intersection_hash": {"type": "string"}, "input": {"type": "array", "items": {"type": "object"}}}, "additionalProperties": false}, + "event": {"type": "object", "required": ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"]}, + "to_talos": {"type": "object", "required": ["v", "type"], "properties": {"v": {"const": 1}}, "unevaluatedProperties": false, "oneOf": [ + {"properties": {"type": {"const": "open"}, "huddle": {"$ref": "#/$defs/uuid"}, "agent": {"$ref": "#/$defs/hex"}, "proofs": {"type": "object", "required": ["agent_record", "added"], "properties": {"agent_record": {"$ref": "#/$defs/event"}, "added": {"$ref": "#/$defs/event"}}, "additionalProperties": false}, "seed": {"$ref": "#/$defs/seed"}, "profile": {"type": "object", "required": ["voice", "persona", "aliases"], "properties": {"voice": {"type": "string"}, "persona": {"type": "string"}, "aliases": {"type": "array", "items": {"type": "string"}}}, "additionalProperties": false}}, "required": ["type", "huddle", "agent", "proofs", "seed", "profile"]}, + {"properties": {"type": {"const": "resume"}, "lease_id": {"type": "string"}}, "required": ["type", "lease_id"]}, + {"properties": {"type": {"const": "seed"}, "intersection_hash": {"type": "string"}, "input": {"type": "array", "items": {"type": "object"}}}, "required": ["type", "intersection_hash", "input"]}, + {"properties": {"type": {"const": "speaker"}, "span": {"$ref": "#/$defs/n"}, "pubkey": {"$ref": "#/$defs/hex"}, "role": {"enum": ["owner", "admitted"]}, "echo": {"type": "boolean"}, "t_ms": {"$ref": "#/$defs/n"}}, "required": ["type", "span", "pubkey", "role", "echo", "t_ms"]}, + {"properties": {"type": {"const": "onset"}, "pubkey": {"$ref": "#/$defs/hex"}}, "required": ["type", "pubkey"]}, + {"properties": {"type": {"const": "replay"}, "span": {"$ref": "#/$defs/n"}, "from_ms": {"$ref": "#/$defs/n"}, "to_ms": {"$ref": "#/$defs/n"}}, "required": ["type", "span", "from_ms", "to_ms"]}, + {"properties": {"type": {"const": "replay_end"}, "span": {"$ref": "#/$defs/n"}}, "required": ["type", "span"]}, + {"properties": {"type": {"const": "roster"}, "humans": {"type": "array", "items": {"$ref": "#/$defs/hex"}}, "voices": {"type": "array", "items": {"$ref": "#/$defs/hex"}}}, "required": ["type", "humans", "voices"]}, + {"properties": {"type": {"const": "tool_result"}, "id": {"type": "string"}, "ok": {"type": "boolean"}, "text": {"type": "string"}}, "required": ["type", "id", "ok", "text"]}, + {"properties": {"type": {"const": "handoff"}, "kind": {"enum": ["money", "card"]}, "text": {"type": "string"}}, "required": ["type", "kind", "text"]}, + {"properties": {"type": {"const": "pong"}, "t": {"$ref": "#/$defs/n"}}, "required": ["type", "t"]} + ]}, + "to_pc": {"type": "object", "required": ["v", "type"], "properties": {"v": {"const": 1}}, "unevaluatedProperties": false, "oneOf": [ + {"properties": {"type": {"const": "opened"}, "lease_id": {"type": "string"}, "expires_at": {"$ref": "#/$defs/n"}}, "required": ["type", "lease_id", "expires_at"]}, + {"properties": {"type": {"const": "transcript"}, "dir": {"enum": ["in", "out"]}, "delta": {"type": "string"}, "start_ms": {"$ref": "#/$defs/n"}, "end_ms": {"$ref": "#/$defs/n"}}, "required": ["type", "dir", "delta", "start_ms", "end_ms"]}, + {"properties": {"type": {"const": "tool"}, "id": {"type": "string"}, "name": {"type": "string"}, "args": {"type": "object"}}, "required": ["type", "id", "name", "args"]}, + {"properties": {"type": {"const": "floor"}, "holder": {"oneOf": [{"$ref": "#/$defs/hex"}, {"type": "null"}]}, "token": {"$ref": "#/$defs/n"}}, "required": ["type", "holder", "token"]}, + {"properties": {"type": {"const": "mute"}, "on": {"type": "boolean"}}, "required": ["type", "on"]}, + {"properties": {"type": {"const": "quiet"}}, "required": ["type"]}, + {"properties": {"type": {"const": "awake"}, "expires_at": {"$ref": "#/$defs/n"}}, "required": ["type", "expires_at"]}, + {"properties": {"type": {"const": "reseed"}, "reason": {"type": "string"}}, "required": ["type", "reason"]}, + {"properties": {"type": {"const": "replay_req"}, "from_ms": {"$ref": "#/$defs/n"}, "to_ms": {"$ref": "#/$defs/n"}}, "required": ["type", "from_ms", "to_ms"]}, + {"properties": {"type": {"const": "elevated"}, "until": {"$ref": "#/$defs/n"}}, "required": ["type", "until"]}, + {"properties": {"type": {"const": "secret"}, "from_ms": {"$ref": "#/$defs/n"}, "to_ms": {"$ref": "#/$defs/n"}}, "required": ["type", "from_ms", "to_ms"]}, + {"properties": {"type": {"const": "meter"}, "seconds": {"$ref": "#/$defs/n"}, "left_usd": {"type": "number"}, "today_left_s": {"$ref": "#/$defs/n"}}, "required": ["type", "seconds", "left_usd", "today_left_s"]}, + {"properties": {"type": {"const": "notice"}, "kind": {"enum": ["cap80", "cap100", "offline"]}, "text": {"type": "string"}}, "required": ["type", "kind", "text"]}, + {"properties": {"type": {"const": "summary"}, "text": {"type": "string"}, "read_channels": {"type": "array", "items": {"$ref": "#/$defs/uuid"}}}, "required": ["type", "text", "read_channels"]}, + {"properties": {"type": {"const": "ping"}, "t": {"$ref": "#/$defs/n"}}, "required": ["type", "t"]}, + {"properties": {"type": {"const": "close"}, "reason": {"type": "string"}}, "required": ["type", "reason"]} + ]} + } +} diff --git a/crates/buzz-huddle-bridge/src/frames.rs b/crates/buzz-huddle-bridge/src/frames.rs new file mode 100644 index 00000000000..74ff1362e74 --- /dev/null +++ b/crates/buzz-huddle-bridge/src/frames.rs @@ -0,0 +1,385 @@ +//! Frames v1 between a PC voice bridge and Talos `/df/voice`. +//! +//! JSON text frames carry `"v": 1` and a `type`; audio is binary PCM16 LE, mono, 24 kHz. +//! [`ToTalos`] and [`ToPc`] are the two directions: each side parses only the other side's +//! frames, and unknown fields are refused. `frames-v1/schema.json` and one fixture per frame in +//! `frames-v1/fixtures/` are the contract Talos copies. +//! +//! Clocks: +//! - `t_ms`, `start_ms` and `end_ms` are session audio ms: ms of PCM the PC has sent on this +//! socket, live and replayed alike, so both sides count the same samples. +//! - `from_ms` and `to_ms` are unix ms on the sender's clock. The PC widens each `secret` +//! window by a skew margin on both sides (a knob, 1 s to start) and starts dropping on +//! receipt even if `from_ms` is still ahead on its own clock, so skew only widens a secret +//! window. `replay` windows are best effort on the same clock. +//! - `expires_at` and `until` are unix seconds. +//! +//! Between `replay` and `replay_end` the PC sends only the replayed PCM, no live audio. +//! +//! Binding: Talos refuses an `open` whose `agent` is not the socket's NIP-98 signer, and a +//! `resume` whose lease is not keyed to that pubkey and huddle. `speaker`, `onset` and `roster` +//! grant no authority; Talos uses them only for turns, waking and labels (design §3.6). + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; + +/// The `v` every frame carries. An incompatible change bumps it in both repos on the same day. +pub const VERSION: u64 = 1; + +/// PC to Talos. Kept one per line so it reads as the design's list. +#[rustfmt::skip] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ToTalos { + Open { huddle: String, agent: String, proofs: Proofs, seed: Seed, profile: Profile }, + Resume { lease_id: String }, + Seed(Seed), + Speaker { span: u64, pubkey: String, role: Role, echo: bool, t_ms: u64 }, + Onset { pubkey: String }, + Replay { span: u64, from_ms: u64, to_ms: u64 }, + ReplayEnd { span: u64 }, + Roster { humans: Vec, voices: Vec }, + ToolResult { id: String, ok: bool, text: String }, + Handoff { kind: HandoffKind, text: String }, + Pong { t: u64 }, +} + +/// Talos to PC. +#[rustfmt::skip] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ToPc { + Opened { lease_id: String, expires_at: u64 }, + Transcript { dir: Dir, delta: String, start_ms: u64, end_ms: u64 }, + Tool { id: String, name: String, args: Value }, + Floor { holder: Option, token: u64 }, + Mute { on: bool }, + Quiet {}, + Awake { expires_at: u64 }, + Reseed { reason: String }, + ReplayReq { from_ms: u64, to_ms: u64 }, + Elevated { until: u64 }, + Secret { from_ms: u64, to_ms: u64 }, + Meter { seconds: u64, left_usd: f64, today_left_s: u64 }, + Notice { kind: NoticeKind, text: String }, + Summary { text: String, read_channels: Vec }, + Ping { t: u64 }, + Close { reason: String }, +} + +/// Signed events, kept as raw JSON for Talos to verify: the owner's 30177 for the agent and +/// the relay's 44100 adding it to this huddle. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Proofs { + pub agent_record: Value, + pub added: Value, +} + +/// Startup context built on the PC under the room intersection; `input` is GPT-Live `input`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Seed { + pub intersection_hash: String, + pub input: Vec, +} + +/// The agent's voice profile: a built-in voice name or custom voice id. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Profile { + pub voice: String, + pub persona: String, + pub aliases: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Role { + Owner, + Admitted, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Dir { + In, + Out, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HandoffKind { + Money, + Card, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NoticeKind { + Cap80, + Cap100, + Offline, +} + +/// Parse one text frame of one direction, refusing any other version. +pub fn parse(text: &str) -> Result { + let mut value: Value = serde_json::from_str(text).map_err(|e| format!("frame: {e}"))?; + let v = value.as_object_mut().and_then(|m| m.remove("v")); + if v.as_ref().and_then(Value::as_u64) != Some(VERSION) { + return Err("frame: not v1".into()); + } + T::deserialize(value).map_err(|e| format!("frame: {e}")) +} + +/// Serialize one text frame with its `v`. +pub fn to_text(frame: &T) -> String { + let mut value = serde_json::to_value(frame).expect("frames are plain data"); + value["v"] = VERSION.into(); + value.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/frames-v1"); + + fn fixtures() -> Vec<(String, Value)> { + let mut out: Vec<_> = std::fs::read_dir(format!("{DIR}/fixtures")) + .unwrap() + .map(|e| { + let path = e.unwrap().path(); + let text = std::fs::read_to_string(&path).unwrap(); + let name = path.file_stem().unwrap().to_string_lossy().into_owned(); + (name, serde_json::from_str(&text).unwrap()) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + + fn schema() -> Value { + let text = std::fs::read_to_string(format!("{DIR}/schema.json")).unwrap(); + serde_json::from_str(&text).unwrap() + } + + fn round_trip(value: &Value) -> Option { + let frame = parse::(&value.to_string()).ok()?; + Some(serde_json::from_str(&to_text(&frame)).unwrap()) + } + + #[test] + fn every_fixture_round_trips_in_exactly_one_direction() { + let fixtures = fixtures(); + assert_eq!(fixtures.len(), 27, "one fixture per frame"); + let mut to_talos = 0; + for (name, value) in fixtures { + assert_eq!(value["type"], name.as_str(), "{name}: named after its type"); + let back = match (round_trip::(&value), round_trip::(&value)) { + (Some(back), None) => { + to_talos += 1; + back + } + (None, Some(back)) => back, + other => panic!("{name}: parsed as {other:?}"), + }; + assert_eq!(back, value, "{name}: round trip"); + let mut extra = value.clone(); + extra["claimed_role"] = "owner".into(); + assert!( + round_trip::(&extra).is_none(), + "{name}: extra field" + ); + assert!(round_trip::(&extra).is_none(), "{name}: extra field"); + } + assert_eq!(to_talos, 11, "PC to Talos frames"); + } + + /// A small JSON Schema walker for the keywords schema.json uses. It returns the property + /// names it evaluated, for `unevaluatedProperties`. + fn check(root: &Value, s: &Value, v: &Value, at: &str) -> Result, String> { + let fail = |what: &str| Err(format!("{at}: {what}")); + let mut seen = BTreeSet::new(); + if let Some(r) = s["$ref"].as_str() { + let def = &root["$defs"][r.strip_prefix("#/$defs/").unwrap()]; + seen.extend(check(root, def, v, at)?); + } + if s.get("const").is_some_and(|c| c != v) { + return fail("const"); + } + if s.get("enum") + .is_some_and(|e| !e.as_array().unwrap().contains(v)) + { + return fail("enum"); + } + let typed = match s["type"].as_str() { + None => true, + Some("string") => v.is_string(), + Some("integer") => v.is_i64() || v.is_u64(), + Some("number") => v.is_number(), + Some("boolean") => v.is_boolean(), + Some("object") => v.is_object(), + Some("array") => v.is_array(), + Some("null") => v.is_null(), + Some(t) => panic!("type {t}"), + }; + if !typed { + return fail("type"); + } + match s["pattern"].as_str() { + None => {} + Some("^[0-9a-f]{64}$") => { + let h = v.as_str().unwrap(); + if h.len() != 64 || !h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return fail("hex"); + } + } + Some(p) => panic!("pattern {p}: teach this walker"), + } + if s["format"] == "uuid" && uuid::Uuid::parse_str(v.as_str().unwrap()).is_err() { + return fail("uuid"); + } + if s["minimum"] + .as_f64() + .is_some_and(|m| v.as_f64().unwrap() < m) + { + return fail("minimum"); + } + for k in s["required"].as_array().into_iter().flatten() { + if v.get(k.as_str().unwrap()).is_none() { + return fail(&format!("required {k}")); + } + } + if let (Some(props), Some(obj)) = (s["properties"].as_object(), v.as_object()) { + for (k, sub) in props.iter().filter(|(k, _)| obj.contains_key(*k)) { + check(root, sub, &obj[k], &format!("{at}.{k}"))?; + seen.insert(k.clone()); + } + if s["additionalProperties"] == false && obj.keys().any(|k| !props.contains_key(k)) { + return fail("additional property"); + } + } + for (i, item) in v.as_array().into_iter().flatten().enumerate() { + check(root, &s["items"], item, &format!("{at}[{i}]"))?; + } + if let Some(branches) = s["oneOf"].as_array() { + let ok: Vec<_> = branches + .iter() + .filter_map(|b| check(root, b, v, at).ok()) + .collect(); + match ok.as_slice() { + [one] => seen.extend(one.iter().cloned()), + _ => return fail(&format!("{} oneOf branches match", ok.len())), + } + } + let obj = v.as_object(); + if s["unevaluatedProperties"] == false + && obj.is_some_and(|o| o.keys().any(|k| !seen.contains(k))) + { + return fail("unevaluated property"); + } + Ok(seen) + } + + /// The schema Talos copies accepts every fixture, in the same direction Rust parses it, + /// and refuses an extra field on any of them. + #[test] + fn schema_accepts_each_fixture_in_its_direction() { + let root = schema(); + let (talos, pc) = (&root["$defs"]["to_talos"], &root["$defs"]["to_pc"]); + let count = |d: &Value| d["oneOf"].as_array().unwrap().len(); + assert_eq!((count(talos), count(pc)), (11, 16), "one branch per frame"); + for (name, value) in fixtures() { + check(&root, &root, &value, &name).unwrap_or_else(|e| panic!("{e}")); + let in_talos = check(&root, talos, &value, &name).is_ok(); + assert_ne!(in_talos, check(&root, pc, &value, &name).is_ok(), "{name}"); + assert_eq!(in_talos, round_trip::(&value).is_some(), "{name}"); + let mut extra = value.clone(); + extra["x"] = 1.into(); + assert!(check(&root, &root, &extra, &name).is_err(), "{name}: extra"); + } + all_required(&root); + } + + /// The contract has no optional fields: every declared property is required. + fn all_required(s: &Value) { + match s { + Value::Object(m) => { + for k in m + .get("properties") + .and_then(Value::as_object) + .into_iter() + .flatten() + { + let required = m.get("required").and_then(Value::as_array); + assert!( + required.is_some_and(|r| r.contains(&k.0.as_str().into())), + "{k:?}" + ); + } + m.values().for_each(all_required); + } + Value::Array(a) => a.iter().for_each(all_required), + _ => {} + } + } + + /// Fixtures show one value of each enum; the schema must list exactly Rust's values. + #[test] + fn schema_enums_match_rust() { + let root = schema(); + let enum_of = |dir: &str, ty: &str, field: &str| { + let branches = root["$defs"][dir]["oneOf"].as_array().unwrap(); + let branch = branches + .iter() + .find(|b| b["properties"]["type"]["const"] == ty); + branch.unwrap()["properties"][field]["enum"].clone() + }; + use serde_json::json; + let role = json!([Role::Owner, Role::Admitted]); + assert_eq!(enum_of("to_talos", "speaker", "role"), role); + let kind = json!([HandoffKind::Money, HandoffKind::Card]); + assert_eq!(enum_of("to_talos", "handoff", "kind"), kind); + assert_eq!( + enum_of("to_pc", "transcript", "dir"), + json!([Dir::In, Dir::Out]) + ); + let notice = json!([NoticeKind::Cap80, NoticeKind::Cap100, NoticeKind::Offline]); + assert_eq!(enum_of("to_pc", "notice", "kind"), notice); + } + + /// The walker bites on drift the schema must catch, one fault per case. + #[test] + fn schema_refuses_bad_values() { + let root = schema(); + let hex = "a".repeat(64); + let bad = [ + r#"{"v":1,"type":"onset","pubkey":"Russell"}"#.to_owned(), + r#"{"v":1,"type":"tool","id":"c","name":"n","args":"gh api -X DELETE"}"#.to_owned(), + format!( + r#"{{"v":1,"type":"speaker","span":1,"pubkey":"{hex}","role":"guest","echo":false,"t_ms":0}}"# + ), + r#"{"v":1,"type":"notice","kind":"cap_80","text":""}"#.to_owned(), + r#"{"v":1,"type":"summary","text":"","read_channels":["general"]}"#.to_owned(), + r#"{"v":1,"type":"hello"}"#.to_owned(), + r#"{"v":1,"type":"ping","t":-1}"#.to_owned(), + r#"{"v":2,"type":"ping","t":1}"#.to_owned(), + ]; + for text in bad { + let value: Value = serde_json::from_str(&text).unwrap(); + assert!(check(&root, &root, &value, &text).is_err(), "{text}"); + } + } + + #[test] + fn other_versions_are_refused() { + assert!(parse::(r#"{"v":2,"type":"ping","t":1}"#).is_err()); + assert!(parse::(r#"{"type":"ping","t":1}"#).is_err()); + assert_eq!( + parse::(r#"{"v":1,"type":"ping","t":1}"#), + Ok(ToPc::Ping { t: 1 }) + ); + } +} diff --git a/crates/buzz-huddle-bridge/src/input.rs b/crates/buzz-huddle-bridge/src/input.rs new file mode 100644 index 00000000000..3ddec9861f2 --- /dev/null +++ b/crates/buzz-huddle-bridge/src/input.rs @@ -0,0 +1,1089 @@ +//! Room audio toward one voice. Each peer is decoded on its own; agents and peers the agent's +//! author gate doesn't admit are dropped before decode; speech comes from decoded PCM energy +//! (never the client-written `level_dbov`); one human at a time is forwarded, resampled to +//! 24 kHz, with a send log; each peer keeps an 8 s ring, bounded by age, that never holds a +//! secret window or echo; and on a non-isolated route the voice doesn't hear a peer while its +//! own output plays (half-duplex). +//! +//! The caller owns the socket and the clock: it feeds relay frames and control messages in, +//! calls [`Input::tick`] every 10 ms, and sends what comes out to Talos. Every `now_ms` is unix +//! ms on a monotonic clock (a start time plus `Instant` elapsed), so no window or age jumps. + +use crate::frames::{Role, ToTalos}; +use buzz_audio_client::connection::{parse_peers, Peer}; +use buzz_audio_client::jitter::PeerJitterBuffer; +use buzz_audio_client::wire::{audio_level_dbov, parse_relay_frame}; +use serde::Deserialize; +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::ops::Range; +use std::path::Path; + +const TICK_MS: u64 = 10; +const RING_MS: u64 = 8_000; +const IDLE_MS: u64 = 500; +const CHUNK: usize = 480; // 20 ms at 24 kHz. +const SENT_CAP: usize = 4_096; +const TAPS: usize = 31; +/// A tick gap past this is a stall: audio queued through it is dropped, never played late. +const STALL_MS: u64 = 5 * TICK_MS; + +/// Who the voice may hear. The caller computes `admitted` with the agent's author gate and +/// `agents` from the room's `bot` roles, trusted 30177 records, Talos, and this agent itself. +#[derive(Clone, Debug, Default)] +pub struct Policy { + pub owner: String, + pub agents: HashSet, + pub admitted: HashSet, +} + +impl Policy { + fn allows(&self, pubkey: &str) -> bool { + !self.agents.contains(pubkey) && (pubkey == self.owner || self.admitted.contains(pubkey)) + } +} + +/// Calibration knobs; probe P measures the real values. +#[derive(Clone, Debug)] +pub struct Tuning { + /// Decoded-PCM RMS level (dBov) at or above which a 10 ms frame is speech. + pub speech_dbov: i8, + /// Sustained speech before `onset`; this much audio is also sent ahead of the span. + pub onset_ms: u64, + /// Silence before the forwarded human releases the floor. + pub hangover_ms: u64, + /// Echo tail after the voice's output, for the owner's desktop. ponytail: both tails are + /// guesses until probe P measures them from `output_until`, the desktop's and the phone's. + pub desktop_tail_ms: u64, + /// Echo tail for every other peer. + pub remote_tail_ms: u64, + /// Widening on both sides of each `secret` window, for clock skew with Talos. + pub secret_skew_ms: u64, + /// A talker this long without a pause yields the floor to any fresh talker, so steady + /// noise can't hold it. ponytail: a per-peer noise floor is the upgrade, after probe P. + pub floor_cap_ms: u64, +} + +impl Default for Tuning { + fn default() -> Self { + Self { + speech_dbov: -45, + onset_ms: 100, + hangover_ms: 300, + desktop_tail_ms: 300, + remote_tail_ms: 600, + secret_skew_ms: 1_000, + floor_cap_ms: 30_000, + } + } +} + +/// The desktop's output route as it applies to one huddle: `here` when the desktop's audio is +/// in it, `isolated` when that output is headphones. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Route { + pub here: bool, + pub isolated: bool, +} + +/// Read the desktop's route file, `{isolated, huddle, updated_at}` in unix s, for `huddle` at +/// `now_ms`. The desktop rewrites it every 5 s while in a huddle and deletes it on leaving. A +/// missing or unreadable file, one for another huddle, or one 3 heartbeats stale means not here +/// and not isolated. Re-read it every few seconds: going stale changes nothing on disk. +pub fn read_route(path: &Path, huddle: &str, now_ms: u64) -> Route { + #[derive(Deserialize)] + struct File { + isolated: bool, + huddle: String, + updated_at: u64, + } + let file: Option = std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()); + let fresh = |f: &File| f.updated_at.saturating_mul(1000).abs_diff(now_ms) <= 15_000; + file.filter(|f| f.huddle == huddle && fresh(f)) + .map_or_else(Route::default, |f| Route { + here: true, + isolated: f.isolated, + }) +} + +/// What the caller sends on to Talos. +#[derive(Debug, PartialEq)] +#[allow(clippy::large_enum_variant)] // Only onset and speaker frames come out of here. +pub enum Out { + Frame(ToTalos), + /// PCM16 LE, 24 kHz mono, 20 ms. + Pcm(Vec), +} + +/// One send-log entry: this span's audio filled session ms `from_ms..to_ms`. +#[derive(Clone, Debug, PartialEq)] +pub struct Sent { + pub span: u64, + pub pubkey: String, + pub role: Role, + pub echo: bool, + pub from_ms: u64, + pub to_ms: u64, +} + +struct Floor { + index: u8, + span: u64, + echo: bool, +} + +struct Slot { + key: (String, u8), + jitter: PeerJitterBuffer, + down: Halfband, + ring: VecDeque<(u64, Vec)>, + last: Vec, + last_packet_ms: u64, + loud_ms: u64, + quiet_ms: u64, + talking: bool, + since_ms: u64, + echo: bool, + /// An onset not yet sent because it fell in the echo. + onset: bool, +} + +impl Slot { + /// Advance speech state by one tick; true on onset. + fn hear(&mut self, loud: bool, now: u64, t: &Tuning) -> bool { + if loud { + self.loud_ms += TICK_MS; + self.quiet_ms = 0; + } else { + self.quiet_ms += TICK_MS; + if !self.talking { + self.loud_ms = 0; + } + } + if !self.talking && self.loud_ms >= t.onset_ms { + self.talking = true; + self.since_ms = now; + return true; + } + if self.talking && self.quiet_ms >= t.hangover_ms { + self.talking = false; + self.loud_ms = 0; + } + false + } +} + +#[derive(Default)] +pub struct Input { + policy: Policy, + tuning: Tuning, + route: Route, + roster: HashMap, + slots: HashMap, + floor: Option, + next_span: u64, + awake: bool, + session_ms: u64, + pending: Vec, + sent: VecDeque, + output_until_ms: u64, + secret: Vec>, + last_tick_ms: Option, +} + +impl Input { + pub fn new(policy: Policy, tuning: Tuning) -> Self { + Self { + policy, + tuning, + ..Self::default() + } + } + + pub fn set_policy(&mut self, policy: Policy) { + self.policy = policy; + self.prune(); + } + + pub fn set_route(&mut self, route: Route) { + self.route = route; + } + + /// Replace the peer map from a relay snapshot (the initial `Joined::peers`). + pub fn set_roster(&mut self, peers: &[Peer]) { + self.roster.clear(); + self.add_peers(peers); + } + + /// Add or replace peers; a slot whose occupant changed loses its state. + fn add_peers(&mut self, peers: &[Peer]) { + let entries = peers.iter().map(|p| (p.index, (p.pubkey.clone(), p.epoch))); + self.roster.extend(entries); + self.prune(); + } + + /// Apply a relay control message. `roster` is the full snapshot; `joined` adds its peers + /// (all of them locally, only the newcomer from another pod); `left` drops one. + pub fn on_control(&mut self, msg: &serde_json::Value) { + match msg["type"].as_str() { + Some(kind @ ("joined" | "roster")) => match parse_peers(msg) { + Ok(peers) if kind == "roster" => self.set_roster(&peers), + Ok(peers) => self.add_peers(&peers), + Err(e) => tracing::warn!(error = %e, "huddle bridge: bad roster"), + }, + Some("left") => { + if let Some(index) = msg["peer_index"] + .as_u64() + .and_then(|i| u8::try_from(i).ok()) + { + self.roster.remove(&index); + self.prune(); + } + } + _ => {} + } + } + + /// Queue one relay audio frame. Unknown slots, agents and peers the gate doesn't admit + /// are dropped before decode. + pub fn on_audio(&mut self, data: &[u8], now_ms: u64) { + let Some((index, header, opus)) = parse_relay_frame(data) else { + return; + }; + let Some(key) = self.roster.get(&index) else { + return; + }; + if !self.policy.allows(&key.0) { + return; + } + let slot = match self.slots.entry(index) { + Entry::Occupied(e) => e.into_mut(), + Entry::Vacant(e) => { + let Ok(jitter) = PeerJitterBuffer::new(index) else { + return; + }; + e.insert(Slot { + key: key.clone(), + jitter, + down: Halfband::new(), + ring: VecDeque::new(), + last: Vec::new(), + last_packet_ms: now_ms, + loud_ms: 0, + quiet_ms: 0, + talking: false, + since_ms: 0, + echo: false, + onset: false, + }) + } + }; + if slot + .jitter + .insert_packet(header.seq, header.ts_48k, opus) + .is_ok() + { + slot.last_packet_ms = now_ms; + } + } + + /// The voice's session opened (`awake`) or closed (`quiet`). Waking starts a new session + /// clock and send log; while quiet only `onset` goes out. + pub fn set_awake(&mut self, awake: bool) { + // A repeated `awake` keeps the clock, the floor and the audio already counted. + if awake == self.awake { + return; + } + if awake { + self.session_ms = 0; + self.sent.clear(); + } + self.awake = awake; + self.floor = None; + self.pending.clear(); + } + + /// The voice's own output plays until `unix_ms` (set by the output path). + pub fn set_output_until(&mut self, unix_ms: u64) { + self.output_until_ms = self.output_until_ms.max(unix_ms); + } + + /// Talos marked unix ms `from_ms..to_ms`, on its clock, secret; it arrived at `now_ms`. The + /// window is widened by the skew margin on both sides and starts by `now_ms` at the latest. + /// Its audio leaves every ring, nothing arriving inside it is recorded or forwarded, and what + /// is still queued at its end is dropped. ponytail: judged by arrival, so a packet the network + /// holds past the window still plays; the RTP timestamp is the upgrade. + pub fn add_secret(&mut self, from_ms: u64, to_ms: u64, now_ms: u64) { + let skew = self.tuning.secret_skew_ms; + let window = + from_ms.saturating_sub(skew).min(now_ms)..to_ms.max(from_ms).saturating_add(skew); + for slot in self.slots.values_mut() { + slot.ring.retain(|(t, _)| !window.contains(t)); + } + // Up to 10 ms not yet sent: silence it, keeping the session clock. + self.pending.fill(0); + if window.end > now_ms { + self.secret.push(window); + } + } + + /// Answer `replay_req` with one peer's ring audio in unix ms `from_ms..to_ms`: `replay`, the + /// PCM, `replay_end`, all sent before the next tick's output. It counts on the session clock + /// and in the send log like live audio. Nothing goes out while the voice is quiet. + pub fn replay(&mut self, index: u8, from_ms: u64, to_ms: u64, now_ms: u64) -> Vec { + let mut out = Vec::new(); + if !self.awake { + return out; + } + // Live audio already counted goes first, padded to a whole chunk. + let pad = (CHUNK - self.pending.len() % CHUNK) % CHUNK; + self.pending.resize(self.pending.len() + pad, 0); + self.session_ms += pad as u64 * 1000 / 24_000; + self.send(&mut out); + self.next_span += 1; + let span = self.next_span; + out.push(Out::Frame(ToTalos::Replay { + span, + from_ms, + to_ms, + })); + let mut pcm = self.ring_pcm(index, from_ms, to_ms, now_ms); + pcm.resize(pcm.len().next_multiple_of(CHUNK), 0); + let from = self.session_ms; + self.session_ms += pcm.len() as u64 * 1000 / 24_000; + if let Some(slot) = self.slots.get(&index).filter(|_| !pcm.is_empty()) { + let pubkey = slot.key.0.clone(); + self.log_sent(Sent { + span, + role: self.role(&pubkey), + pubkey, + echo: false, + from_ms: from, + to_ms: self.session_ms, + }); + } + self.pending = pcm; + self.send(&mut out); + out.push(Out::Frame(ToTalos::ReplayEnd { span })); + out + } + + /// One peer's ring audio in unix ms `from_ms..to_ms`. Audio older than the ring at `now_ms` + /// is never returned, even if no tick has evicted it yet. + fn ring_pcm(&self, index: u8, from_ms: u64, to_ms: u64, now_ms: u64) -> Vec { + let ring = self.slots.get(&index).into_iter().flat_map(|s| &s.ring); + ring.filter(|(t, _)| (from_ms..to_ms).contains(t) && fresh(*t, now_ms)) + .flat_map(|(_, pcm)| pcm.iter().copied()) + .collect() + } + + /// Send-log entries overlapping session ms `start_ms..end_ms`, for mapping a transcript + /// delta back to a peer. + pub fn who_said(&self, start_ms: u64, end_ms: u64) -> impl Iterator { + self.sent + .iter() + .filter(move |s| s.from_ms < end_ms && start_ms < s.to_ms) + } + + pub fn roster_frame(&self) -> ToTalos { + let mut keys: Vec<&String> = self.roster.values().map(|(pk, _)| pk).collect(); + keys.sort(); + keys.dedup(); + let (voices, humans): (Vec<&String>, Vec<&String>) = keys + .into_iter() + .partition(|pk| self.policy.agents.contains(*pk)); + ToTalos::Roster { + humans: humans.into_iter().cloned().collect(), + voices: voices.into_iter().cloned().collect(), + } + } + + /// Advance 10 ms. + pub fn tick(&mut self, now_ms: u64) -> Vec { + let mut out = Vec::new(); + let t = self.tuning.clone(); + let windows = self.secret.len(); + self.secret.retain(|w| w.end > now_ms); + // Windows start by their receipt, so one not yet over holds through a clock step back. + let secret = !self.secret.is_empty(); + // What a stall, a clock step or a window's end left queued must not play late. + let stalled = self + .last_tick_ms + .is_some_and(|last| now_ms < last || now_ms - last > STALL_MS); + self.last_tick_ms = Some(now_ms); + if stalled || self.secret.len() < windows { + self.flush(); + } + let owner_peers = self + .roster + .values() + .filter(|(pk, _)| *pk == self.policy.owner) + .count(); + let desktop_here = self.route.here && owner_peers == 1; + for slot in self.slots.values_mut() { + slot.last.clear(); + // Age bounds the ring, so sparse ticks can't keep audio past RING_MS. + slot.ring.retain(|(t, _)| fresh(*t, now_ms)); + let active = + now_ms.saturating_sub(slot.last_packet_ms) < IDLE_MS || !slot.jitter.is_empty(); + let pcm = if active { + slot.jitter.get_audio().ok().map(|(pcm, _)| pcm) + } else { + None + }; + let loud = pcm + .as_ref() + .is_some_and(|pcm| audio_level_dbov(pcm) >= t.speech_dbov); + // ponytail: the route names the desktop only when the owner has one peer here. + // Upgrade: have the desktop write its peer index into the route file. + let desktop = desktop_here && slot.key.0 == self.policy.owner; + let tail = if desktop { + t.desktop_tail_ms + } else { + t.remote_tail_ms + }; + slot.echo = !(desktop && self.route.isolated) + && now_ms < self.output_until_ms.saturating_add(tail); + if let Some(pcm) = pcm { + slot.last = slot.down.process(&pcm); + // A replay or preroll never carries secret audio or the voice's own echo. + if !secret && !slot.echo { + slot.ring.push_back((now_ms, slot.last.clone())); + if slot.ring.len() as u64 > RING_MS / TICK_MS { + slot.ring.pop_front(); + } + } + } + // An onset in the echo goes out once the echo clears, if the talk goes on. + let onset = slot.hear(loud, now_ms, &t); + slot.onset = (slot.onset || onset) && slot.talking; + if slot.onset && !slot.echo { + slot.onset = false; + out.push(Out::Frame(ToTalos::Onset { + pubkey: slot.key.0.clone(), + })); + } + } + if !self.awake { + return out; + } + // A talker in its echo, or one that hasn't paused for `floor_cap_ms`, yields the floor + // to any other talker. + let demoted = |s: &Slot| s.echo || now_ms.saturating_sub(s.since_ms) >= t.floor_cap_ms; + let rival = |held: u8| { + let mut others = self.slots.iter().filter(|(i, _)| **i != held); + others.any(|(_, s)| s.talking && !demoted(s)) + }; + if self.floor.as_ref().is_some_and(|f| { + let slot = self.slots.get(&f.index); + !slot.is_some_and(|s| s.talking) || (slot.is_some_and(demoted) && rival(f.index)) + }) { + self.floor = None; + } + let mut preroll = false; + if self.floor.is_none() { + let next = self + .slots + .iter() + .filter(|(_, s)| s.talking) + .min_by_key(|(i, s)| (demoted(s), s.since_ms, **i)); + if let Some((&index, slot)) = next { + self.next_span += 1; + let floor = Floor { + index, + span: self.next_span, + echo: slot.echo, + }; + out.push(Out::Frame(ToTalos::Speaker { + span: floor.span, + pubkey: slot.key.0.clone(), + role: self.role(&slot.key.0), + echo: floor.echo, + t_ms: self.session_ms, + })); + self.floor = Some(floor); + preroll = true; + } + } + let held = self.floor.as_ref().map(|f| (f, &self.slots[&f.index])); + let audio: Vec = match held { + Some((_, slot)) if !slot.echo && !secret && preroll => slot + .ring + .iter() + .filter(|(at, _)| now_ms - at < t.onset_ms) + .flat_map(|(_, pcm)| pcm.iter().copied()) + .collect(), + Some((_, slot)) if !slot.echo && !secret => slot.last.clone(), + _ => Vec::new(), + }; + let audio = if audio.is_empty() { + vec![0; CHUNK / 2] + } else { + audio + }; + let from = self.session_ms; + self.session_ms += audio.len() as u64 * 1000 / 24_000; + if let Some((floor, slot)) = held.filter(|(_, s)| !s.echo && !secret) { + let (span, echo, pubkey) = (floor.span, floor.echo, slot.key.0.clone()); + self.log_sent(Sent { + span, + role: self.role(&pubkey), + pubkey, + echo, + from_ms: from, + to_ms: self.session_ms, + }); + } + self.pending.extend(audio); + self.send(&mut out); + out + } + + /// Send pending audio in whole chunks. + fn send(&mut self, out: &mut Vec) { + while self.pending.len() >= CHUNK { + let chunk = self.pending.drain(..CHUNK).flat_map(|s| s.to_le_bytes()); + out.push(Out::Pcm(chunk.collect())); + } + } + + /// Drop every slot's queued packets and filter state; one that can't restart goes. + fn flush(&mut self) { + self.slots.retain(|&index, slot| { + slot.down = Halfband::new(); + PeerJitterBuffer::new(index) + .map(|j| slot.jitter = j) + .is_ok() + }); + self.prune(); + } + + fn role(&self, pubkey: &str) -> Role { + if pubkey == self.policy.owner { + Role::Owner + } else { + Role::Admitted + } + } + + fn log_sent(&mut self, sent: Sent) { + if let Some(last) = self.sent.back_mut() { + if last.span == sent.span && last.to_ms == sent.from_ms { + last.to_ms = sent.to_ms; + return; + } + } + if self.sent.len() == SENT_CAP { + self.sent.pop_front(); + } + self.sent.push_back(sent); + } + + /// Drop slots whose occupant changed or is no longer admitted, and a floor without its slot. + fn prune(&mut self) { + self.slots + .retain(|i, s| self.roster.get(i) == Some(&s.key) && self.policy.allows(&s.key.0)); + if self + .floor + .as_ref() + .is_some_and(|f| !self.slots.contains_key(&f.index)) + { + self.floor = None; + } + } +} + +/// A ring entry stamped `t` is still inside the ring at `now_ms`. One stamped ahead of `now_ms` +/// (the clock stepped back) is not, so it can't outlive the window. +fn fresh(t: u64, now_ms: u64) -> bool { + t <= now_ms && now_ms - t < RING_MS +} + +/// 48 to 24 kHz: a 31-tap Hamming-windowed half-band low-pass, then every second sample. It +/// passes speech flat and takes about 30 dB off 14 kHz and 50 dB off 16 kHz and up. +struct Halfband { + taps: [f32; TAPS], + hist: Vec, +} + +impl Halfband { + fn new() -> Self { + let mid = (TAPS - 1) as f32 / 2.0; + let mut taps = [0.0; TAPS]; + for (i, tap) in taps.iter_mut().enumerate() { + let n = i as f32 - mid; + let sinc = if n == 0.0 { + 0.5 + } else { + (std::f32::consts::FRAC_PI_2 * n).sin() / (std::f32::consts::PI * n) + }; + *tap = + sinc * (0.54 - 0.46 * (std::f32::consts::TAU * i as f32 / (TAPS - 1) as f32).cos()); + } + let sum: f32 = taps.iter().sum(); + taps.iter_mut().for_each(|tap| *tap /= sum); + Self { + taps, + hist: vec![0.0; TAPS - 1], + } + } + + fn process(&mut self, input: &[f32]) -> Vec { + self.hist.extend_from_slice(input); + let n = (self.hist.len() - (TAPS - 1)) / 2; + let out = (0..n) + .map(|i| { + let s: f32 = self.hist[2 * i..2 * i + TAPS] + .iter() + .zip(&self.taps) + .map(|(a, b)| a * b) + .sum(); + (s.clamp(-1.0, 1.0) * 32_767.0) as i16 + }) + .collect(); + self.hist.drain(..2 * n); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_audio_client::encoder::AudioEncoder; + use std::f32::consts::TAU; + + const H: &str = "5b6f8c2e-1d3a-4c8e-9f21-7a0b3c4d5e6f"; + const OWNER: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const GUEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const STRANGER: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + const T0: u64 = 1_793_318_400_000; + + fn input() -> Input { + // The agent is admitted too, as an author gate open to anyone would: the agent set wins. + let admitted = [GUEST.into(), AGENT.into()].into(); + let policy = Policy { + owner: OWNER.into(), + agents: [AGENT.into()].into(), + admitted, + }; + let mut input = Input::new(policy, Tuning::default()); + let peers = [(1, OWNER), (2, GUEST), (3, AGENT), (4, STRANGER)]; + input.set_roster(&peers.map(|(index, pk)| Peer { + index, + pubkey: pk.into(), + epoch: 0, + })); + input + } + + /// A peer sending 20 ms of a 1 kHz tone; `level` overwrites the client-written level byte. + struct Talker(u8, AudioEncoder, f32, Option); + + fn talker(index: u8, amp: f32, level: Option) -> Talker { + Talker(index, AudioEncoder::new().unwrap(), amp, level) + } + + impl Talker { + fn frame(&mut self) -> Vec { + let Talker(index, enc, amp, level) = self; + let pcm: Vec = (0..960) + .map(|i| *amp * (TAU * 1000.0 * i as f32 / 48_000.0).sin()) + .collect(); + let mut frame = vec![*index]; + frame.extend(enc.encode(&pcm).unwrap()); + if let Some(level) = level { + frame[7] = *level as u8; + } + frame + } + } + + fn run(input: &mut Input, now: &mut u64, ms: u64, talkers: &mut [Talker]) -> Vec { + let mut out = Vec::new(); + for _ in 0..ms / TICK_MS { + for talker in talkers.iter_mut().filter(|_| now.is_multiple_of(20)) { + input.on_audio(&talker.frame(), *now); + } + out.extend(input.tick(*now)); + *now += TICK_MS; + } + out + } + + fn ring(input: &Input, from: u64, to: u64, now: u64) -> usize { + input.ring_pcm(1, from, to, now).len() / (CHUNK / 2) + } + + fn frames(out: &[Out]) -> Vec<&ToTalos> { + out.iter() + .filter_map(|o| if let Out::Frame(f) = o { Some(f) } else { None }) + .collect() + } + + fn voiced(out: &[Out]) -> bool { + let loud = |b: &[u8]| { + b.chunks(2) + .any(|s| i16::from_le_bytes([s[0], s[1]]).abs() > 1000) + }; + out.iter().any(|o| matches!(o, Out::Pcm(b) if loud(b))) + } + + #[test] + fn speech_comes_from_decoded_pcm_never_the_level_byte() { + let mut input = input(); + input.set_awake(true); + let mut now = T0; + let mut owner = [talker(1, 0.0, Some(0))]; + let out = run(&mut input, &mut now, 400, &mut owner); + assert!( + frames(&out).is_empty() && !voiced(&out), + "silence claiming full level" + ); + owner[0].2 = 0.3; + owner[0].3 = Some(-127); + let out = run(&mut input, &mut now, 400, &mut owner); + assert_eq!( + frames(&out)[0], + &ToTalos::Onset { + pubkey: OWNER.into() + } + ); + assert!(matches!( + frames(&out)[1], + ToTalos::Speaker { + span: 1, + role: Role::Owner, + echo: false, + .. + } + )); + assert!(voiced(&out), "decoded, resampled and forwarded"); + assert!(out + .iter() + .all(|o| !matches!(o, Out::Pcm(b) if b.len() != 2 * CHUNK))); + assert!(input.who_said(0, u64::MAX).all(|s| s.pubkey == OWNER)); + // A repeated `awake` keeps the span and the audio already counted. + let mut chunks = 0; + for _ in 0..10 { + input.set_awake(true); + let out = run(&mut input, &mut now, 10, &mut owner); + chunks += out.iter().filter(|o| matches!(o, Out::Pcm(_))).count(); + } + assert_eq!(chunks, 5); + assert!(input.who_said(0, u64::MAX).all(|s| s.span == 1)); + } + + #[test] + fn agents_and_peers_the_gate_refuses_are_never_heard() { + let mut input = input(); + input.set_awake(true); + let mut now = T0; + let mut others = [ + talker(3, 0.3, None), + talker(4, 0.3, None), + talker(9, 0.3, None), + ]; + let out = run(&mut input, &mut now, 400, &mut others); + assert!(frames(&out).is_empty() && !voiced(&out)); + // A new occupant of slot 4 that the gate admits is heard. From another pod `joined` + // lists only the newcomer, and everyone else stays. + let peers = serde_json::json!([{"pubkey": GUEST, "peer_index": 4, "epoch": 1}]); + input.on_control(&serde_json::json!({"type": "joined", "peers": peers})); + let out = run(&mut input, &mut now, 400, &mut others); + let onset = |pk: &str| ToTalos::Onset { pubkey: pk.into() }; + assert_eq!(frames(&out)[0], &onset(GUEST)); + let out = run(&mut input, &mut now, 400, &mut [talker(1, 0.3, None)]); + assert!( + frames(&out).contains(&&onset(OWNER)), + "the owner is still heard" + ); + let roster = |humans: &[&str], voices: &[&str]| ToTalos::Roster { + humans: humans.iter().map(|pk| pk.to_string()).collect(), + voices: voices.iter().map(|pk| pk.to_string()).collect(), + }; + input.on_control(&serde_json::json!({"type": "left", "peer_index": 2})); + assert_eq!(input.roster_frame(), roster(&[OWNER, GUEST], &[AGENT])); + // `roster` is the full snapshot. + let peers = serde_json::json!([{"pubkey": OWNER, "peer_index": 1}]); + input.on_control(&serde_json::json!({"type": "roster", "peers": peers})); + assert_eq!(input.roster_frame(), roster(&[OWNER], &[])); + } + + #[test] + fn one_human_at_a_time_and_the_send_log_names_them() { + let mut input = input(); + input.set_awake(true); + let mut now = T0; + let mut ts = [talker(1, 0.3, None), talker(2, 0.0, None)]; + run(&mut input, &mut now, 300, &mut ts); + ts[1].2 = 0.3; + let both = run(&mut input, &mut now, 500, &mut ts); + ts[0].2 = 0.0; + let after = run(&mut input, &mut now, 800, &mut ts); + assert_eq!( + frames(&both), + [&ToTalos::Onset { + pubkey: GUEST.into() + }], + "the guest waits" + ); + assert!(matches!( + frames(&after)[..], + [ToTalos::Speaker { + span: 2, + role: Role::Admitted, + .. + }] + )); + let log: Vec<_> = input + .who_said(0, u64::MAX) + .map(|s| (s.span, s.pubkey.as_str())) + .collect(); + assert_eq!(log, [(1, OWNER), (2, GUEST)]); + let owner_end = input.who_said(0, u64::MAX).next().unwrap().to_ms; + assert_eq!( + input + .who_said(owner_end, owner_end + 10) + .next() + .unwrap() + .pubkey, + GUEST + ); + } + + #[test] + fn half_duplex_unless_the_desktop_here_is_isolated() { + let mut now = T0; + for (here, echo) in [(false, true), (true, false)] { + let mut input = input(); + input.set_awake(true); + input.set_route(Route { + here, + isolated: true, + }); + input.set_output_until(now + 1_000); + let mut owner = [talker(1, 0.3, None)]; + let start = now; + let during = run(&mut input, &mut now, 600, &mut owner); + let onset = frames(&during) + .iter() + .any(|f| matches!(f, ToTalos::Onset { .. })); + assert_eq!(onset, !echo, "here: {here}"); + assert!(frames(&during) + .iter() + .any(|f| matches!(f, ToTalos::Speaker { echo: e, .. } if *e == echo))); + assert_eq!(voiced(&during), !echo, "here: {here}"); + let after = run(&mut input, &mut now, 1_200, &mut owner); + assert!(voiced(&after) && input.who_said(0, u64::MAX).all(|s| s.echo == echo)); + // Echo lasts to start + 1.6 s (1 s of output, 600 ms tail) and never enters the ring. + let kept = if echo { 20 } else { 180 }; + assert_eq!(ring(&input, start, now, now), kept); + } + // Quiet, an onset in the echo goes out once the echo clears. + let mut input = input(); + input.set_output_until(now + 100); + let out = run(&mut input, &mut now, 1_000, &mut [talker(2, 0.3, None)]); + let onset = ToTalos::Onset { + pubkey: GUEST.into(), + }; + assert_eq!(frames(&out), [&onset]); + } + + #[test] + fn echo_and_steady_noise_yield_the_floor() { + for echo in [true, false] { + let mut input = input(); + input.set_awake(true); + let mut now = T0; + if echo { + // The owner on headphones, the guest on speakers while the voice talks. + input.set_route(Route { + here: true, + isolated: true, + }); + input.set_output_until(now + 3_000); + } else { + input.tuning.floor_cap_ms = 1_000; + } + // The guest takes the floor, then the owner talks. + let mut ts = [talker(2, 0.3, None), talker(1, 0.0, None)]; + run(&mut input, &mut now, 1_500, &mut ts); + ts[1].2 = 0.3; + run(&mut input, &mut now, 500, &mut ts); + let owner = input.who_said(0, u64::MAX).any(|s| s.pubkey == OWNER); + assert!(owner, "echo: {echo}"); + } + } + + #[test] + fn audio_queued_through_a_stall_never_plays_late() { + // The secret window, or the output and its tail, end while no tick runs. + for secret in [true, false] { + let mut input = input(); + input.set_awake(true); + let mut now = T0; + if secret { + input.add_secret(now, now + 100, now); + } else { + input.set_output_until(now + 500); + } + input.tick(now); + let mut owner = talker(1, 0.3, None); + for _ in 0..55 { + input.on_audio(&owner.frame(), now); + now += 20; + } + owner.2 = 0.0; + let out = run(&mut input, &mut now, 300, &mut [owner]); + let pcm = input.ring_pcm(1, T0, now, now); + assert!( + !voiced(&out) && pcm.iter().all(|s| s.abs() < 1000), + "{secret}" + ); + } + } + + #[test] + fn a_replay_counts_on_the_session_clock_and_names_its_peer() { + let mut input = input(); + input.set_awake(true); + let mut now = T0; + let mut ts = [talker(1, 0.3, None), talker(2, 0.3, None)]; + let mut out = run(&mut input, &mut now, 500, &mut ts); + let replay = input.replay(2, now - 300, now, now); + let at = input.session_ms; + assert!(matches!( + frames(&replay)[..], + [ + ToTalos::Replay { span: 2, .. }, + ToTalos::ReplayEnd { span: 2 } + ] + )); + out.extend(replay); + out.extend(run(&mut input, &mut now, 200, &mut ts)); + // Every sample sent counts once, so Talos's ms map back to the right peer. + let chunks = out.iter().filter(|o| matches!(o, Out::Pcm(_))).count(); + let sent = chunks * CHUNK + input.pending.len(); + assert_eq!(sent, input.session_ms as usize * 24); + let who = |from, to| { + let log = input.who_said(from, to); + log.map(|s| s.pubkey.as_str()).collect::>() + }; + assert_eq!(who(at - 300, at), [GUEST]); + assert_eq!(who(at, at + 200), [OWNER]); + input.set_awake(false); + assert!(input.replay(2, now - 300, now, now).is_empty(), "quiet"); + } + + #[test] + fn secret_windows_widen_by_the_skew_and_never_reach_the_ring() { + let mut input = input(); + let mut now = T0; + let mut owner = [talker(1, 0.3, None)]; + let out = run(&mut input, &mut now, 3_000, &mut owner); + assert!( + out.iter() + .all(|o| matches!(o, Out::Frame(ToTalos::Onset { .. }))), + "quiet: onset only" + ); + assert_eq!(ring(&input, T0, now, now), 300); + // 1 s of skew on each side: T0 + 0.5 s to T0 + 2.6 s goes. + input.add_secret(T0 + 1_500, T0 + 1_600, now); + assert_eq!(ring(&input, T0, now, now), 90); + assert_eq!(ring(&input, T0 + 500, T0 + 2_600, now), 0); + // A window still ahead on this clock starts dropping on receipt: start to start + 3.1 s. + // Forwarding when it lands, with 10 ms of voiced audio still pending. + input.set_awake(true); + assert!(voiced(&run(&mut input, &mut now, 200, &mut owner))); + let start = now; + input.add_secret(start + 2_000, start + 2_100, start); + let out = run(&mut input, &mut now, 3_100, &mut owner); + assert!(!voiced(&out) && ring(&input, start, now, now) == 0); + // Its end drops what is still queued. + run(&mut input, &mut now, 10, &mut owner); + assert!(input.slots[&1].jitter.is_empty()); + assert!( + voiced(&run(&mut input, &mut now, 200, &mut owner)), + "after the window" + ); + // A window not yet over holds when the clock steps back. + input.add_secret(now, now + 100, now); + now -= 5_000; + let start = now; + let out = run(&mut input, &mut now, 500, &mut owner); + assert!(!voiced(&out) && ring(&input, start, now, now) == 0); + } + + #[test] + fn sparse_ticks_never_replay_audio_older_than_the_ring() { + let mut input = input(); + let mut now = T0; + run(&mut input, &mut now, 500, &mut [talker(1, 0.3, None)]); + // No tick since, and a replay still ages entries out: T0 + 0.26 s to T0 + 0.49 s is left. + assert_eq!(ring(&input, 0, u64::MAX, T0 + RING_MS + 250), 24); + assert_eq!(ring(&input, 0, u64::MAX, T0 + RING_MS + 490), 0); + // One late tick evicts by age, where a count bound would have kept all 50 entries; one + // after the clock steps back drops entries stamped ahead of it. + for late in [T0 + 20_000, T0 - 5_000] { + let mut input = self::input(); + let mut now = T0; + run(&mut input, &mut now, 500, &mut [talker(1, 0.3, None)]); + input.tick(late); + assert!(input.slots[&1].ring.iter().all(|(t, _)| *t == late)); + } + } + + #[test] + fn the_floor_starts_with_the_onset_audio() { + let mut input = input(); + input.set_awake(true); + let mut now = T0; + run(&mut input, &mut now, 300, &mut [talker(1, 0.3, None)]); + // 30 ticks of 10 ms, plus the onset's 100 ms sent at once in place of one tick. + assert_eq!(input.who_said(0, u64::MAX).last().unwrap().to_ms, 390); + } + + #[test] + fn halfband_keeps_speech_and_stops_aliases() { + let gain = |hz: f32| { + let tone: Vec = (0..4_800) + .map(|i| 0.5 * (TAU * hz * i as f32 / 48_000.0).sin()) + .collect(); + let out = Halfband::new().process(&tone); + assert_eq!(out.len(), 2_400); + out[100..].iter().map(|s| s.unsigned_abs()).max().unwrap() as f32 / (0.5 * 32_767.0) + }; + assert!((gain(1_000.0) - 1.0).abs() < 0.05); + assert!(gain(14_000.0) < 0.04 && gain(16_000.0) < 0.004 && gain(20_000.0) < 0.01); + } + + #[test] + fn route_file_counts_only_for_this_huddle_while_fresh() { + let path = + std::env::temp_dir().join(format!("huddle-bridge-route-{}.json", std::process::id())); + let read = |text: String| { + std::fs::write(&path, text).unwrap(); + read_route(&path, H, T0) + }; + let file = |huddle: &str, age_s: u64| { + let at = T0 / 1000 - age_s; + format!(r#"{{"isolated":true,"huddle":"{huddle}","updated_at":{at}}}"#) + }; + let on = Route { + here: true, + isolated: true, + }; + assert_eq!(read(file(H, 5)), on); + assert_eq!(read(file("other", 5)), Route::default()); + // A desktop that crashed, or left without deleting it: three heartbeats missed. + assert_eq!(read(file(H, 20)), Route::default()); + let unnamed = format!(r#"{{"isolated":true,"updated_at":{}}}"#, T0 / 1000); + assert_eq!(read(unnamed), Route::default()); + assert_eq!(read("not json".into()), Route::default()); + std::fs::remove_file(&path).unwrap(); + assert_eq!(read_route(&path, H, T0), Route::default()); + } +} diff --git a/crates/buzz-huddle-bridge/src/join.rs b/crates/buzz-huddle-bridge/src/join.rs new file mode 100644 index 00000000000..5733c642588 --- /dev/null +++ b/crates/buzz-huddle-bridge/src/join.rs @@ -0,0 +1,120 @@ +//! Audio admission as the agent: the relay's NIP-42 challenge signed with the agent key and its +//! NIP-OA owner tag, the same tags buzz-acp's relay auth uses. The protocol version comes from +//! `buzz-audio-client`, so the bridge and the desktop always speak the same one. + +use buzz_audio_client::connection::{connect, Joined}; +use buzz_audio_client::wire::PROTOCOL_VERSION; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use tokio_tungstenite::tungstenite::{client::IntoClientRequest, handshake::client::Request}; +use uuid::Uuid; + +/// The upgrade request for a huddle's audio room. It is a request, not a URL, so the caller +/// can add headers (a `Nostr-Federated-Identity` assertion once NIP-FI is enforced). +pub fn audio_request(relay_url: &str, huddle: Uuid) -> Result { + let url = format!("{}/huddle/{huddle}/audio", relay_url.trim_end_matches('/')); + url.into_client_request() + .map_err(|e| format!("audio request: {e}")) +} + +/// The NIP-42 `relay` tag for the socket this request dials: its bare origin, which is what +/// the relay expects (`scheme://`). Taking it from the request means a signed answer +/// can never name a different host than the one that issued the challenge. +fn relay_tag(request: &Request) -> Result { + let uri = request.uri(); + match (uri.scheme_str(), uri.authority()) { + (Some(scheme), Some(authority)) => Ok(format!("{scheme}://{authority}")), + _ => Err("audio request has no origin".into()), + } +} + +/// The kind 22242 answer to the audio challenge. +fn auth_event( + keys: &Keys, + relay: &str, + challenge: &str, + owner_tag: Option<&Tag>, +) -> Result { + let mut tags = vec![ + Tag::parse(["relay", relay]).map_err(|e| format!("relay tag: {e}"))?, + Tag::parse(["challenge", challenge]).map_err(|e| format!("challenge tag: {e}"))?, + ]; + tags.extend(owner_tag.cloned()); + EventBuilder::new(Kind::Authentication, "") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| format!("sign audio auth: {e}")) +} + +/// Join the audio room. A refusal is logged with the version this side speaks, so an +/// `upgrade_required` from a room pinned to another version shows in the logs. +/// +/// The caller installs a rustls `CryptoProvider` first (buzz-acp does at startup); a `wss://` +/// join panics without one. +pub async fn join( + request: Request, + parent: Uuid, + keys: &Keys, + owner_tag: Option<&Tag>, +) -> Result { + let relay = relay_tag(&request)?; + let parent = parent.to_string(); + connect(request, Some(&parent), None, |challenge| async move { + let event = auth_event(keys, &relay, &challenge, owner_tag)?; + serde_json::to_value(&event).map_err(|e| format!("audio auth json: {e}")) + }) + .await + .inspect_err(|e| { + tracing::warn!(protocol_version = PROTOCOL_VERSION, error = %e, "huddle audio join refused") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_event_carries_relay_challenge_and_owner_tag() { + let (agent, owner) = (Keys::generate(), Keys::generate()); + let owner_tag = Tag::parse(["auth", &owner.public_key().to_hex(), "", "sig"]).unwrap(); + let event = auth_event(&agent, "wss://relay.test", "c-1", Some(&owner_tag)).unwrap(); + event.verify().unwrap(); + assert_eq!(event.kind, Kind::Custom(22242)); + assert_eq!(event.pubkey, agent.public_key()); + let tags: Vec> = event.tags.iter().map(|t| t.clone().to_vec()).collect(); + assert_eq!(tags[0], ["relay", "wss://relay.test"]); + assert_eq!(tags[1], ["challenge", "c-1"]); + assert_eq!(tags[2], owner_tag.to_vec()); + assert_eq!( + auth_event(&agent, "wss://r", "c", None).unwrap().tags.len(), + 2 + ); + } + + #[test] + fn audio_request_targets_the_room_and_takes_headers() { + let huddle = Uuid::nil(); + let mut request = audio_request("wss://relay.test/", huddle).unwrap(); + assert_eq!( + request.uri().to_string(), + format!("wss://relay.test/huddle/{huddle}/audio") + ); + request + .headers_mut() + .insert("Nostr-Federated-Identity", "token".parse().unwrap()); + assert!(audio_request("not a url", huddle).is_err()); + } + + /// The relay tag is the dialed origin, port included, whatever path the relay URL had. + #[test] + fn relay_tag_is_the_dialed_origin() { + let huddle = Uuid::nil(); + for (relay_url, tag) in [ + ("wss://relay.test/", "wss://relay.test"), + ("ws://127.0.0.1:3000", "ws://127.0.0.1:3000"), + ("wss://relay.test:8443/base", "wss://relay.test:8443"), + ] { + let request = audio_request(relay_url, huddle).unwrap(); + assert_eq!(relay_tag(&request).unwrap(), tag, "{relay_url}"); + } + } +} diff --git a/crates/buzz-huddle-bridge/src/lib.rs b/crates/buzz-huddle-bridge/src/lib.rs new file mode 100644 index 00000000000..9d9bd68fed8 --- /dev/null +++ b/crates/buzz-huddle-bridge/src/lib.rs @@ -0,0 +1,10 @@ +//! The huddle audio bridge between a relay audio room and one agent voice on Talos. +//! +//! [`join`] admits the agent to the room, [`input`] turns room audio into what the voice may +//! hear, and [`frames`] is the v1 contract with Talos. Output, reconnect and the socket loop +//! come in later slices. This crate loads no keys and starts no tasks. +#![deny(unsafe_code)] + +pub mod frames; +pub mod input; +pub mod join; diff --git a/crates/buzz-relay-gateway/Cargo.toml b/crates/buzz-relay-gateway/Cargo.toml new file mode 100644 index 00000000000..33243ae84cc --- /dev/null +++ b/crates/buzz-relay-gateway/Cargo.toml @@ -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 } diff --git a/crates/buzz-relay-gateway/README.md b/crates/buzz-relay-gateway/README.md new file mode 100644 index 00000000000..7e03649a3db --- /dev/null +++ b/crates/buzz-relay-gateway/README.md @@ -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 `: + 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 . +``` diff --git a/crates/buzz-relay-gateway/src/auth.rs b/crates/buzz-relay-gateway/src/auth.rs new file mode 100644 index 00000000000..ae8206ca3bb --- /dev/null +++ b/crates/buzz-relay-gateway/src/auth.rs @@ -0,0 +1,289 @@ +//! The link bearer: a single-use NIP-98 event signed by the gateway-auth key. + +use std::{ + collections::HashMap, + sync::{Mutex, PoisonError}, + time::{Duration, Instant}, +}; + +use axum::http::{header::AUTHORIZATION, HeaderMap}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_auth::{nip98_replay::DEFAULT_REPLAY_TTL_SECS, verify_nip98_event}; +use nostr::{Event, EventId, PublicKey}; + +/// Why a bearer was refused. Only [`Reject::code`] is logged: the underlying +/// error text quotes bearer content. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reject { + /// No `Authorization` header. + Missing, + /// Repeated, oversized, not `Nostr `, or not UTF-8. + Malformed, + /// Failed NIP-98: kind, signature, time window, `u` or `method`. + Invalid, + /// A valid signature by a key other than the pinned one. + WrongKey, + /// Not exactly one `nonce` tag of 16 to 128 characters. + NoNonce, + /// The event id was already used. + Replayed, + /// The replay cache is full; the gateway fails closed. + CacheFull, +} + +impl Reject { + /// The fixed reason code for logs. + pub fn code(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Malformed => "malformed", + Self::Invalid => "invalid", + Self::WrongKey => "wrong_key", + Self::NoNonce => "no_nonce", + Self::Replayed => "replayed", + Self::CacheFull => "cache_full", + } + } +} + +/// Checks the link bearer on an upgrade request and returns its event id and +/// `created_at` (unix seconds), which the caller then claims in the [`ReplayCache`]. +pub fn verify_link_bearer( + headers: &HeaderMap, + link_url: &str, + pubkey: &PublicKey, + max_len: usize, +) -> Result<(EventId, u64), Reject> { + let mut values = headers.get_all(AUTHORIZATION).iter(); + let (value, None) = (values.next().ok_or(Reject::Missing)?, values.next()) else { + return Err(Reject::Malformed); + }; + let json = (value.len() <= max_len) + .then(|| value.to_str().ok()?.strip_prefix("Nostr ")) + .flatten() + .and_then(|b64| STANDARD.decode(b64).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or(Reject::Malformed)?; + verify_nip98_event(&json, link_url, "GET", None).map_err(|_| Reject::Invalid)?; + let event: Event = serde_json::from_str(&json).map_err(|_| Reject::Invalid)?; + if event.pubkey != *pubkey { + return Err(Reject::WrongKey); + } + let mut nonces = event + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|t| t.first().is_some_and(|n| n == "nonce")); + match (nonces.next(), nonces.next()) { + (Some([_, n, ..]), None) if (16..=128).contains(&n.chars().count()) => { + Ok((event.id, event.created_at.as_secs())) + } + _ => Err(Reject::NoNonce), + } +} + +/// How long a claimed id is kept: the relay's replay TTL (twice the ±60 s +/// window) plus 2 s, because the window is inclusive and counts whole seconds, +/// so a bearer dated 60 s ahead stays valid for up to 121 s. +const REPLAY_TTL: Duration = Duration::from_secs(DEFAULT_REPLAY_TTL_SECS + 2); + +/// `verify_nip98_event`'s ±60 s window on `created_at` (the relay's TTL is twice it). +const WINDOW_SECS: u64 = DEFAULT_REPLAY_TTL_SECS / 2; + +/// Event ids of accepted bearers, each kept for `REPLAY_TTL` of monotonic time +/// and, if the wall clock steps back, for as long as the window could still +/// accept its `created_at`. A restart empties it. +pub struct ReplayCache { + cap: usize, + seen: Mutex>, +} + +impl ReplayCache { + /// A cache that holds at most `cap` live ids. + pub fn new(cap: usize) -> Self { + Self { + cap, + seen: Mutex::new(HashMap::new()), + } + } + + /// Claims a bearer `(id, created_at)` once, `now` being monotonic and `wall` + /// unix seconds. Refuses a replay, and refuses every id while full. + pub fn claim( + &self, + (id, created_at): (EventId, u64), + now: Instant, + wall: u64, + ) -> Result<(), Reject> { + let mut seen = self.seen.lock().unwrap_or_else(PoisonError::into_inner); + seen.retain(|_, (at, created)| { + now.saturating_duration_since(*at) < REPLAY_TTL + || wall <= created.saturating_add(WINDOW_SECS + 1) + }); + if seen.contains_key(&id) { + return Err(Reject::Replayed); + } + if seen.len() >= self.cap { + return Err(Reject::CacheFull); + } + seen.insert(id, (now, created_at)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + const URL: &str = "https://gateway.bishop.invalid/v1/link"; + const NONCE: &str = "6f1c2c1e-4b9a-4c71-9a55-0f1d8c3a2b10"; + + fn event(keys: &Keys, kind: u16, tags: &[&[&str]], age: i64) -> Event { + let tags = tags.iter().map(|t| Tag::parse(t.iter().copied()).unwrap()); + let at = Timestamp::from((Timestamp::now().as_secs() as i64 - age) as u64); + EventBuilder::new(Kind::from(kind), "") + .tags(tags) + .custom_created_at(at) + .sign_with_keys(keys) + .unwrap() + } + + fn header(event: &Event) -> String { + format!( + "Nostr {}", + STANDARD.encode(serde_json::to_string(event).unwrap()) + ) + } + + fn good(keys: &Keys) -> String { + let nonce = uuid::Uuid::new_v4().to_string(); + header(&event( + keys, + 27235, + &[&["u", URL], &["method", "GET"], &["nonce", &nonce]], + 0, + )) + } + + fn check(keys: &Keys, values: &[&str]) -> Result<(EventId, u64), Reject> { + let mut headers = HeaderMap::new(); + for v in values { + headers.append(AUTHORIZATION, HeaderValue::from_str(v).unwrap()); + } + verify_link_bearer(&headers, URL, &keys.public_key(), 8 << 10) + } + + #[test] + fn accepts_a_good_bearer_and_url_normalisation() { + let k = Keys::generate(); + assert!(check(&k, &[&good(&k)]).is_ok()); + for u in [ + "https://gateway.bishop.invalid/v1/link/", + "HTTPS://Gateway.Bishop.Invalid/v1/link", + ] { + let b = header(&event( + &k, + 27235, + &[&["u", u], &["method", "get"], &["nonce", NONCE]], + 0, + )); + assert!(check(&k, &[&b]).is_ok(), "{u}"); + } + } + + #[test] + fn refuses_bad_bearers() { + let k = Keys::generate(); + let with = |kind, tags: &[&[&str]], age| header(&event(&k, kind, tags, age)); + let (u, get, nonce) = ( + &["u", URL][..], + &["method", "GET"][..], + &["nonce", NONCE][..], + ); + let mut tampered: serde_json::Value = + serde_json::to_value(event(&k, 27235, &[u, get, nonce], 0)).unwrap(); + tampered["content"] = "x".into(); + let tampered = format!("Nostr {}", STANDARD.encode(tampered.to_string())); + let cases = [ + (good(&Keys::generate()), Reject::WrongKey), + (with(27235, &[u, get, nonce], 120), Reject::Invalid), + (with(27235, &[u, get, nonce], -120), Reject::Invalid), + ( + with( + 27235, + &[&["u", "https://other.invalid/v1/link"], get, nonce], + 0, + ), + Reject::Invalid, + ), + ( + with(27235, &[u, &["method", "POST"], nonce], 0), + Reject::Invalid, + ), + (with(22242, &[u, get, nonce], 0), Reject::Invalid), + (tampered, Reject::Invalid), + (with(27235, &[u, get], 0), Reject::NoNonce), + ( + with( + 27235, + &[u, get, nonce, &["nonce", "a-second-nonce-value"]], + 0, + ), + Reject::NoNonce, + ), + ( + with(27235, &[u, get, &["nonce", "short"]], 0), + Reject::NoNonce, + ), + (good(&k).replacen("Nostr ", "Bearer ", 1), Reject::Malformed), + ("Nostr !!!".to_owned(), Reject::Malformed), + ( + format!("Nostr {}", STANDARD.encode([0xff, 0xfe])), + Reject::Malformed, + ), + (format!("Nostr {}", "A".repeat(8 << 10)), Reject::Malformed), + ]; + for (i, (bearer, want)) in cases.iter().enumerate() { + assert_eq!(check(&k, &[bearer]), Err(*want), "case {i}"); + } + assert_eq!(check(&k, &[]), Err(Reject::Missing)); + assert_eq!(check(&k, &[&good(&k), &good(&k)]), Err(Reject::Malformed)); + } + + #[test] + fn replay_cache_is_single_use_prunes_and_fails_closed() { + let k = Keys::generate(); + let (a, b) = ( + check(&k, &[&good(&k)]).unwrap(), + check(&k, &[&good(&k)]).unwrap(), + ); + let cache = ReplayCache::new(1); + let (now, wall) = (Instant::now(), a.1); + assert_eq!(cache.claim(a, now, wall), Ok(())); + assert_eq!(cache.claim(a, now, wall), Err(Reject::Replayed)); + assert_eq!(cache.claim(b, now, wall), Err(Reject::CacheFull)); + // Still held when the relay's own 120 s would have let it go. + let relay_ttl = now + Duration::from_secs(DEFAULT_REPLAY_TTL_SECS); + assert_eq!(cache.claim(a, relay_ttl, wall + 120), Err(Reject::Replayed)); + assert_eq!(cache.claim(b, now + REPLAY_TTL, wall + 122), Ok(())); + } + + #[test] + fn a_wall_clock_step_back_keeps_the_id_while_the_window_accepts_it() { + let k = Keys::generate(); + let a = check(&k, &[&good(&k)]).unwrap(); + let cache = ReplayCache::new(4); + let now = Instant::now(); + assert_eq!(cache.claim(a, now, a.1), Ok(())); + // Monotonic time is past REPLAY_TTL, but the wall clock reads + // created_at + 60 s, which the verifier still accepts. + let later = now + REPLAY_TTL; + assert_eq!( + cache.claim(a, later, a.1 + WINDOW_SECS), + Err(Reject::Replayed) + ); + assert_eq!(cache.claim(a, later, a.1 + WINDOW_SECS + 2), Ok(())); + } +} diff --git a/crates/buzz-relay-gateway/src/config.rs b/crates/buzz-relay-gateway/src/config.rs new file mode 100644 index 00000000000..57f4df543e7 --- /dev/null +++ b/crates/buzz-relay-gateway/src/config.rs @@ -0,0 +1,234 @@ +//! Environment configuration (all public values) and the fixed limits. + +use std::{net::SocketAddr, time::Duration}; + +use nostr::PublicKey; +use url::Url; + +const BIND: &str = "BUZZ_RELAY_GATEWAY_BIND_ADDR"; +const RELAY: &str = "BUZZ_RELAY_GATEWAY_RELAY_URL"; +const LINK_URL: &str = "BUZZ_RELAY_GATEWAY_LINK_URL"; +const LINK_PUBKEY: &str = "BUZZ_RELAY_GATEWAY_LINK_PUBKEY"; + +/// The gateway's settings. None of them is a secret. +#[derive(Clone, Debug)] +pub struct Config { + /// Listen address; defaults to loopback. + pub bind_addr: SocketAddr, + /// The one relay the gateway reaches, `wss://host[:port]/`. + pub relay_ws: Url, + /// The same relay's HTTP origin, `https://host[:port]/`. + pub relay_http: Url, + /// The `u` tag every link bearer must carry. + pub link_url: String, + /// The gateway-auth public key that signs link bearers. + pub link_pubkey: PublicKey, +} + +/// A missing or invalid variable. It names the variable, never its value. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// A required variable is not set. + #[error("{0} is not set")] + Missing(&'static str), + /// A variable is set but does not parse or is not allowed. + #[error("{0} is invalid")] + Invalid(&'static str), +} + +impl Config { + /// Reads `BUZZ_RELAY_GATEWAY_*` from the process environment. + pub fn from_env() -> Result { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + /// Reads the settings through `get`, so tests need no process environment. + pub fn from_lookup(get: impl Fn(&str) -> Option) -> Result { + let required = |name: &'static str| get(name).ok_or(ConfigError::Missing(name)); + let bind_addr = get(BIND) + .as_deref() + .unwrap_or("127.0.0.1:8080") + .parse() + .map_err(|_| ConfigError::Invalid(BIND))?; + let (relay_ws, relay_http) = + relay_urls(&required(RELAY)?).ok_or(ConfigError::Invalid(RELAY))?; + let link_url = required(LINK_URL)?; + Url::parse(&link_url).map_err(|_| ConfigError::Invalid(LINK_URL))?; + let hex = required(LINK_PUBKEY)?; + let lower_hex = + hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + let link_pubkey = PublicKey::from_hex(&hex) + .ok() + .filter(|_| lower_hex) + .ok_or(ConfigError::Invalid(LINK_PUBKEY))?; + Ok(Self { + bind_addr, + relay_ws, + relay_http, + link_url, + link_pubkey, + }) + } +} + +/// `wss://host[:port]` with no userinfo, path, query or fragment; `ws://` only on loopback. +fn relay_urls(raw: &str) -> Option<(Url, Url)> { + let ws = Url::parse(raw).ok()?; + let loopback = matches!(ws.host_str()?, "127.0.0.1" | "[::1]" | "localhost"); + let scheme = match ws.scheme() { + "wss" => "https", + "ws" if loopback => "http", + _ => return None, + }; + let bare = ws.username().is_empty() + && ws.password().is_none() + && ws.path() == "/" + && ws.query().is_none() + && ws.fragment().is_none(); + let mut http = ws.clone(); + http.set_scheme(scheme).ok()?; + bare.then_some((ws, http)) +} + +/// Fixed limits and timeouts (design §5). Not configurable by env; tests pass smaller values. +#[derive(Clone, Debug)] +pub struct Limits { + /// Links open at once; the next upgrade gets 503. + pub max_links: usize, + /// Longest `Authorization` value, on the link bearer and on forwarded requests. + pub max_auth_header: usize, + /// Bearer ids remembered for replay protection; when full, new bearers get 503. + pub replay_entries: usize, + /// Largest Worker frame; a bigger one closes the link with 1009. + pub max_frame: usize, + /// Largest decoded request body (the relay's own API limit). + pub max_request_body: usize, + /// Largest relay response body; the read stops there. + pub max_response_body: usize, + /// Largest `ws_send` text (the relay's frame limit). + pub max_ws_text: usize, + /// Largest frame the gateway accepts from the relay. + pub max_relay_frame: usize, + /// HTTP requests in flight per link. + pub max_http_inflight: usize, + /// Relay sockets per link. + pub max_sockets: usize, + /// Frames queued for the Worker; a full queue closes the link with 4003. + pub out_frames: usize, + /// Serialized bytes queued for the Worker, with the same rule. + pub out_bytes: usize, + /// Worker frames queued for one relay socket; a full queue closes that socket. + pub relay_queue: usize, + /// How long a link lives before it closes with 4001. + pub link_lifetime: Duration, + /// How long before that the gateway sends `rotate`. + pub rotate_before: Duration, + /// Gateway `ping` interval. + pub ping_every: Duration, + /// A link with no Worker frame for this long closes with 4002. + pub idle: Duration, + /// Reaching the relay: TCP, TLS and, for sockets, the upgrade. + pub connect_timeout: Duration, + /// One whole HTTP exchange with the relay. + pub request_timeout: Duration, + /// A relay socket with no frame (pings included) for this long is closed. + pub relay_silence: Duration, + /// How long shutdown waits for in-flight HTTP requests. + pub shutdown_grace: Duration, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_links: 4, + max_auth_header: 8 << 10, + replay_entries: 1024, + max_frame: 2 << 20, + max_request_body: 1 << 20, + max_response_body: 4 << 20, + max_ws_text: 512 << 10, + max_relay_frame: 1 << 20, + max_http_inflight: 16, + max_sockets: 8, + out_frames: 1024, + out_bytes: 16 << 20, + relay_queue: 64, + link_lifetime: Duration::from_secs(3600), + rotate_before: Duration::from_secs(300), + ping_every: Duration::from_secs(20), + idle: Duration::from_secs(60), + connect_timeout: Duration::from_secs(10), + request_timeout: Duration::from_secs(30), + relay_silence: Duration::from_secs(90), + shutdown_grace: Duration::from_secs(10), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The secp256k1 generator x: a valid key whose secret (1) is public anyway. + const PK: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + fn load(relay: &str, pubkey: &str) -> Result { + Config::from_lookup(|name| match name { + RELAY => Some(relay.to_owned()), + LINK_URL => Some("https://gateway.bishop.invalid/v1/link".to_owned()), + LINK_PUBKEY => Some(pubkey.to_owned()), + _ => None, + }) + } + + #[test] + fn accepts_wss_and_loopback_ws() { + let c = load("wss://relay.example.com", PK).unwrap(); + assert_eq!(c.relay_ws.as_str(), "wss://relay.example.com/"); + assert_eq!(c.relay_http.as_str(), "https://relay.example.com/"); + assert_eq!(c.bind_addr.to_string(), "127.0.0.1:8080"); + for ok in [ + "ws://127.0.0.1:7000", + "ws://localhost:7000/", + "ws://[::1]:7000", + "wss://r.example:444", + ] { + assert!(load(ok, PK).is_ok(), "{ok}"); + } + assert_eq!( + load("ws://127.0.0.1:7000", PK).unwrap().relay_http.as_str(), + "http://127.0.0.1:7000/" + ); + } + + #[test] + fn refuses_other_relay_urls() { + for bad in [ + "ws://example.com", + "wss://relay.example.com/path", + "wss://relay.example.com/?q=1", + "wss://relay.example.com/#f", + "wss://user:pw@relay.example.com", + "https://relay.example.com", + "http://127.0.0.1:7000", + "relay.example.com", + ] { + assert!( + matches!(load(bad, PK), Err(ConfigError::Invalid(RELAY))), + "{bad}" + ); + } + } + + #[test] + fn refuses_bad_pubkey_and_names_missing_variables() { + for bad in [&PK[..63], &PK.to_uppercase(), &format!("{}zz", &PK[..62])] { + assert!(matches!( + load("wss://r.example", bad), + Err(ConfigError::Invalid(LINK_PUBKEY)) + )); + } + let err = Config::from_lookup(|_| None).unwrap_err(); + assert_eq!(err.to_string(), "BUZZ_RELAY_GATEWAY_RELAY_URL is not set"); + } +} diff --git a/crates/buzz-relay-gateway/src/frame.rs b/crates/buzz-relay-gateway/src/frame.rs new file mode 100644 index 00000000000..0486c19ef05 --- /dev/null +++ b/crates/buzz-relay-gateway/src/frame.rs @@ -0,0 +1,411 @@ +//! Link frames, version 1 (design §3): one JSON object per WebSocket text frame. +//! +//! The frame types that carry headers, bodies or relay text do not derive +//! `Debug`, so none of them can reach a log line by accident. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{value::RawValue, Number}; + +use crate::policy::Refused; + +/// A Worker-chosen request or socket id: 1 to 64 of `[A-Za-z0-9_-]`, so it is safe to log. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[serde(try_from = "String")] +pub struct Id(String); + +impl TryFrom for Id { + type Error = &'static str; + + fn try_from(s: String) -> Result { + let valid = (1..=64).contains(&s.len()) + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); + valid.then_some(Self(s)).ok_or("invalid id") + } +} + +impl Id { + /// The id, for logs. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// An `http` frame: one request to forward to the relay. +#[derive(Deserialize)] +pub struct HttpFrame { + /// Unique among this link's requests in flight. + pub id: Id, + /// `GET` or `POST`, matched exactly by [`crate::policy::http_route`]. + pub method: String, + /// Matched exactly by [`crate::policy::http_route`]. + pub path: String, + /// The raw `headers` object, checked by [`crate::policy::request_headers`]. + pub headers: Option>, + /// The request body, standard base64 (POST only). + pub body_b64: Option, +} + +/// A frame from the Worker. +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Inbound { + /// Forward one HTTP request. Parsed on its own: `RawValue` does not + /// survive serde's internally tagged enums. + #[serde(skip)] + Http(HttpFrame), + /// Open a relay socket on `path`. + WsOpen { sid: Id, path: String }, + /// Send `text` on a relay socket, as is. + WsSend { sid: Id, text: String }, + /// Close a relay socket. + WsClose { + sid: Id, + code: Option, + reason: Option, + }, + /// Liveness; answered with a `pong` carrying the same `ts`. + Ping { ts: Option }, + /// The answer to a gateway `ping`. + Pong, + /// A `type` this version does not know; the link stays open. + #[serde(other)] + Unknown, +} + +/// Not JSON, no `type`, or a known type with a missing or mistyped field. +#[derive(Debug, PartialEq, Eq)] +pub struct BadFrame; + +#[derive(Deserialize)] +struct TypeOnly { + #[serde(rename = "type")] + kind: String, +} + +impl Inbound { + /// Parses one text frame. Each type reads only its own fields, so a field + /// that belongs to another type is ignored like any unknown field. + pub fn parse(text: &str) -> Result { + let TypeOnly { kind } = serde_json::from_str(text).map_err(|_| BadFrame)?; + let frame = if kind == "http" { + serde_json::from_str(text).map(Self::Http) + } else { + serde_json::from_str(text) + }; + frame.map_err(|_| BadFrame) + } +} + +/// Which side ended a relay socket. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Side { + /// The relay closed it or the connection dropped. + Relay, + /// The gateway closed it (limits, link end, shutdown, failed open). + Gateway, + /// The Worker asked with `ws_close`. + Worker, +} + +/// The limits announced in `hello`. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct HelloLimits { + pub max_request_body: usize, + pub max_response_body: usize, + pub max_ws_text: usize, + pub max_http_inflight: usize, + pub max_sockets: usize, +} + +/// A frame to the Worker. +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Out { + /// The first frame on every link. + Hello { + v: u8, + link_id: String, + expires_at: u64, + rotate_at: u64, + limits: HelloLimits, + }, + /// The relay's answer, whatever its status. + HttpRes { + id: Id, + status: u16, + headers: BTreeMap<&'static str, String>, + body_b64: String, + ms: u64, + }, + /// The relay accepted the socket upgrade. + WsOpened { sid: Id, ms: u64 }, + /// One text frame from the relay, as is. + WsMsg { sid: Id, text: String }, + /// The end of a socket, sent exactly once per `sid`. + WsClosed { + sid: Id, + by: Side, + code: Option, + reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + http_status: Option, + }, + /// A frame the gateway refused or could not perform. + Error { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sid: Option, + code: ErrorCode, + message: &'static str, + }, + /// The link closes with 4001 at `close_at` (unix seconds). + Rotate { close_at: u64 }, + /// Liveness; the Worker answers `pong`. + Ping { ts: Number }, + /// The answer to a Worker `ping`. + Pong { + #[serde(skip_serializing_if = "Option::is_none")] + ts: Option, + }, +} + +impl Out { + /// An `error` frame with the code's fixed message. + pub fn error(code: ErrorCode, id: Option, sid: Option) -> Self { + Self::Error { + id, + sid, + code, + message: code.message(), + } + } +} + +/// `error.code`. Each code has a fixed message that never quotes frame content. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + BadFrame, + UnknownType, + NotAllowed, + NotSupported, + BadBase64, + BodyTooLarge, + FrameTooLarge, + ResponseTooLarge, + DuplicateId, + UnknownSocket, + Busy, + UpstreamConnect, + UpstreamTimeout, + ShuttingDown, +} + +impl ErrorCode { + /// The fixed English phrase for `message`. + pub fn message(self) -> &'static str { + match self { + Self::BadFrame => "malformed frame", + Self::UnknownType => "unknown frame type", + Self::NotAllowed => "not allowed", + Self::NotSupported => "not supported yet", + Self::BadBase64 => "body_b64 is not valid base64", + Self::BodyTooLarge => "request body too large", + Self::FrameTooLarge => "text frame too large", + Self::ResponseTooLarge => "relay response too large", + Self::DuplicateId => "id already in use", + Self::UnknownSocket => "no such socket", + Self::Busy => "too many requests or sockets in flight", + Self::UpstreamConnect => "could not reach the relay", + Self::UpstreamTimeout => "the relay did not answer in time", + Self::ShuttingDown => "gateway shutting down", + } + } +} + +impl From for ErrorCode { + fn from(refused: Refused) -> Self { + match refused { + Refused::NotAllowed => Self::NotAllowed, + Refused::NotSupported => Self::NotSupported, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn id(s: &str) -> Id { + Id::try_from(s.to_owned()).unwrap() + } + + #[test] + fn parses_every_inbound_type() { + let http = r#"{"type":"http","id":"q-1","method":"POST","path":"/query", + "headers":{"accept":"a","accept":"b"},"body_b64":"e30=","extra":1}"#; + let Ok(Inbound::Http(f)) = Inbound::parse(http) else { + panic!("http") + }; + assert_eq!( + (f.id, f.method.as_str(), f.path.as_str()), + (id("q-1"), "POST", "/query") + ); + // Kept raw, so the policy can still see the duplicated key. + assert_eq!(f.headers.unwrap().get(), r#"{"accept":"a","accept":"b"}"#); + assert_eq!(f.body_b64.as_deref(), Some("e30=")); + let open = Inbound::parse(r#"{"type":"ws_open","sid":"s1","path":"/"}"#); + assert!( + matches!(open, Ok(Inbound::WsOpen { sid, path }) if sid == id("s1") && path == "/") + ); + let send = Inbound::parse(r#"{"type":"ws_send","sid":"s1","text":"[\"AUTH\",{}]"}"#); + assert!(matches!(send, Ok(Inbound::WsSend { text, .. }) if text == r#"["AUTH",{}]"#)); + let close = Inbound::parse(r#"{"type":"ws_close","sid":"s1","code":4000,"reason":"bye"}"#); + assert!( + matches!(close, Ok(Inbound::WsClose { code: Some(4000), reason: Some(r), .. }) if r == "bye") + ); + let ping = Inbound::parse(r#"{"type":"ping","ts":1793149200123}"#); + assert!( + matches!(ping, Ok(Inbound::Ping { ts: Some(ts) }) if ts.to_string() == "1793149200123") + ); + assert!(matches!( + Inbound::parse(r#"{"type":"pong","ts":1}"#), + Ok(Inbound::Pong) + )); + assert!(matches!( + Inbound::parse(r#"{"type":"later"}"#), + Ok(Inbound::Unknown) + )); + assert!(matches!( + Inbound::parse(r#"{"type":"later","code":"x"}"#), + Ok(Inbound::Unknown) + )); + // Another frame type's field, of another type, is still an unknown field here. + for foreign in [ + r#"{"type":"ping","ts":1,"code":"later"}"#, + r#"{"type":"ping","ts":1,"id":7}"#, + r#"{"type":"pong","sid":[]}"#, + r#"{"type":"ws_send","sid":"s1","text":"x","path":5}"#, + r#"{"type":"http","id":"q","method":"GET","path":"/","ts":"x","text":1}"#, + ] { + assert!(Inbound::parse(foreign).is_ok(), "{foreign}"); + } + } + + #[test] + fn refuses_bad_frames_and_ids() { + for bad in [ + "not json", + "[]", + r#"{"id":"x"}"#, + r#"{"type":"http","id":"q","method":"POST"}"#, + r#"{"type":"ws_send","sid":"s1"}"#, + r#"{"type":"ws_open","sid":"has space","path":"/"}"#, + r#"{"type":"ws_open","sid":"","path":"/"}"#, + r#"{"type":"ping","ts":"soon"}"#, + r#"{"type":5}"#, + r#"{"type":"ws_open","sid":"s1","sid":"s2","path":"/"}"#, + ] { + assert!(Inbound::parse(bad).is_err(), "{bad}"); + } + let long = format!( + r#"{{"type":"ws_open","sid":"{}","path":"/"}}"#, + "a".repeat(65) + ); + assert_eq!(Inbound::parse(&long).err(), Some(BadFrame)); + assert!(Id::try_from("A-z_09".to_owned()).is_ok()); + assert!(Id::try_from("a".repeat(64)).is_ok()); + } + + #[test] + fn serializes_every_outbound_type() { + let limits = HelloLimits { + max_request_body: 1, + max_response_body: 2, + max_ws_text: 3, + max_http_inflight: 4, + max_sockets: 5, + }; + let headers = BTreeMap::from([("content-type", "application/json".to_owned())]); + let cases = [ + ( + Out::Hello { + v: 1, + link_id: "l".into(), + expires_at: 10, + rotate_at: 9, + limits, + }, + json!({"type":"hello","v":1,"link_id":"l","expires_at":10,"rotate_at":9,"limits": + {"max_request_body":1,"max_response_body":2,"max_ws_text":3,"max_http_inflight":4,"max_sockets":5}}), + ), + ( + Out::HttpRes { + id: id("q"), + status: 429, + headers, + body_b64: "e30=".into(), + ms: 7, + }, + json!({"type":"http_res","id":"q","status":429,"headers":{"content-type":"application/json"},"body_b64":"e30=","ms":7}), + ), + ( + Out::WsOpened { + sid: id("s"), + ms: 3, + }, + json!({"type":"ws_opened","sid":"s","ms":3}), + ), + ( + Out::WsMsg { + sid: id("s"), + text: "[1]".into(), + }, + json!({"type":"ws_msg","sid":"s","text":"[1]"}), + ), + ( + Out::WsClosed { + sid: id("s"), + by: Side::Gateway, + code: None, + reason: "upgrade_refused".into(), + http_status: Some(503), + }, + json!({"type":"ws_closed","sid":"s","by":"gateway","code":null,"reason":"upgrade_refused","http_status":503}), + ), + ( + Out::WsClosed { + sid: id("s"), + by: Side::Relay, + code: Some(1012), + reason: "r".into(), + http_status: None, + }, + json!({"type":"ws_closed","sid":"s","by":"relay","code":1012,"reason":"r"}), + ), + ( + Out::error(ErrorCode::DuplicateId, Some(id("q")), None), + json!({"type":"error","id":"q","code":"duplicate_id","message":"id already in use"}), + ), + ( + Out::error(ErrorCode::UnknownType, None, None), + json!({"type":"error","code":"unknown_type","message":"unknown frame type"}), + ), + ( + Out::Rotate { close_at: 5 }, + json!({"type":"rotate","close_at":5}), + ), + (Out::Ping { ts: 6.into() }, json!({"type":"ping","ts":6})), + (Out::Pong { ts: None }, json!({"type":"pong"})), + ]; + for (frame, want) in cases { + assert_eq!(serde_json::to_value(&frame).unwrap(), want); + } + } +} diff --git a/crates/buzz-relay-gateway/src/http_forward.rs b/crates/buzz-relay-gateway/src/http_forward.rs new file mode 100644 index 00000000000..72e8e037940 --- /dev/null +++ b/crates/buzz-relay-gateway/src/http_forward.rs @@ -0,0 +1,72 @@ +//! HTTP forwarding: one upstream request per `http` frame, never retried. + +use std::collections::BTreeMap; + +use axum::http::{HeaderMap, Method}; +use reqwest::{redirect::Policy, Client}; +use url::Url; + +use crate::{frame::ErrorCode, policy, Config, Limits}; + +/// The relay's answer, read up to the response cap. +pub(crate) struct Answer { + pub status: u16, + pub headers: BTreeMap<&'static str, String>, + pub body: Vec, +} + +/// The one client for the relay: no redirects, no proxy, no decompression. +pub(crate) fn client(config: &Config, limits: &Limits) -> reqwest::Result { + // The no_* calls also hold if workspace feature unification turns a codec on. + Client::builder() + .redirect(Policy::none()) + .no_proxy() + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .https_only(config.relay_http.scheme() == "https") + .connect_timeout(limits.connect_timeout) + .timeout(limits.request_timeout) + .user_agent(concat!("buzz-relay-gateway/", env!("CARGO_PKG_VERSION"))) + .build() +} + +/// Sends one request and reads the whole answer, whatever its status. +pub(crate) async fn forward( + client: &Client, + method: Method, + url: Url, + headers: HeaderMap, + body: Option>, + max_body: usize, +) -> Result { + let mut request = client.request(method, url).headers(headers); + if let Some(body) = body { + request = request.body(body); + } + let mut response = request.send().await.map_err(upstream)?; + let status = response.status().as_u16(); + let headers = policy::response_headers(response.headers()); + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(upstream)? { + if body.len() + chunk.len() > max_body { + return Err(ErrorCode::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + Ok(Answer { + status, + headers, + body, + }) +} + +fn upstream(e: reqwest::Error) -> ErrorCode { + // An expired connect_timeout is also a timeout, but then nothing reached the relay. + if e.is_timeout() && !e.is_connect() { + ErrorCode::UpstreamTimeout + } else { + ErrorCode::UpstreamConnect + } +} diff --git a/crates/buzz-relay-gateway/src/lib.rs b/crates/buzz-relay-gateway/src/lib.rs new file mode 100644 index 00000000000..1818516ca0f --- /dev/null +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -0,0 +1,149 @@ +//! Buzz relay gateway: a keyless forwarder between one Worker link and one Buzz relay. +//! +//! The Worker keeps every key and signs every event; the gateway checks the +//! link bearer and forwards bytes. It holds no secret. +#![deny(unsafe_code)] + +pub mod auth; +pub mod config; +pub mod frame; +mod http_forward; +mod link; +pub mod policy; +mod relay_ws; + +use std::{ + future::IntoFuture, + sync::Arc, + time::{Duration, Instant}, +}; + +use axum::{ + extract::{ws::rejection::WebSocketUpgradeRejection, State, WebSocketUpgrade}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use nostr::Timestamp; +use tokio::{net::TcpListener, sync::Semaphore}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; +use tracing_subscriber::EnvFilter; + +pub use config::{Config, Limits}; + +// hyper's graceful wait never ends for a connection that stopped mid-request, +// so once the work is done the open connections get this long. +const LAST_REQUESTS: Duration = Duration::from_secs(2); + +struct AppState { + config: Config, + limits: Limits, + replay: auth::ReplayCache, + links: Arc, + shutdown: CancellationToken, + tracker: TaskTracker, + client: reqwest::Client, +} + +/// Serves `/healthz` and `/v1/link` on `listener`. Once `shutdown` is +/// cancelled both answer 503, every link drains and closes, and this returns. +pub async fn serve( + listener: TcpListener, + config: Config, + limits: Limits, + shutdown: CancellationToken, +) -> std::io::Result<()> { + let state = Arc::new(AppState { + client: http_forward::client(&config, &limits).map_err(std::io::Error::other)?, + replay: auth::ReplayCache::new(limits.replay_entries), + links: Arc::new(Semaphore::new(limits.max_links)), + shutdown: shutdown.clone(), + tracker: TaskTracker::new(), + config, + limits, + }); + let app = Router::new() + .route("/healthz", get(healthz)) + .route("/v1/link", get(link)) + .with_state(state.clone()); + // Keep accepting (and answering 503) until every link has closed. + let tracker = state.tracker.clone(); + let drained = async move { + shutdown.cancelled().await; + state.tracker.close(); + state.tracker.wait().await; + }; + let server = axum::serve(listener, app).with_graceful_shutdown(drained); + tokio::select! { + served = server.into_future() => served, + // wait() returns only after `drained` has closed the tracker and every link has ended. + () = async { + tracker.wait().await; + tokio::time::sleep(LAST_REQUESTS).await; + } => Ok(()), + } +} + +/// The log filter: `directives` in `RUST_LOG` syntax, or `info` if they do not +/// parse. The WebSocket crates stay off at any level, because at trace they +/// print whole frames: bearers, NIP-98 headers, bodies and relay messages. +pub fn log_filter(directives: &str) -> EnvFilter { + let filter = EnvFilter::try_new(directives).unwrap_or_else(|_| EnvFilter::new("info")); + // shortcut: a more specific target (tungstenite::protocol=trace) still wins; + // whoever sets RUST_LOG also picks the image, so pinning harder buys nothing. + ["tungstenite=off", "tokio_tungstenite=off"] + .into_iter() + .filter_map(|d| d.parse().ok()) + .fold(filter, EnvFilter::add_directive) +} + +async fn healthz(State(s): State>) -> Response { + if s.shutdown.is_cancelled() { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } else { + "ok".into_response() + } +} + +async fn link( + State(s): State>, + headers: HeaderMap, + upgrade: Result, +) -> Response { + if s.shutdown.is_cancelled() { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + let Ok(permit) = s.links.clone().try_acquire_owned() else { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + let claimed = auth::verify_link_bearer( + &headers, + &s.config.link_url, + &s.config.link_pubkey, + s.limits.max_auth_header, + ) + .and_then(|bearer| { + s.replay + .claim(bearer, Instant::now(), Timestamp::now().as_secs()) + }); + match (claimed, upgrade) { + (Err(reject), _) => { + tracing::warn!(reason = reject.code(), "link bearer refused"); + if reject == auth::Reject::CacheFull { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + let body = Json(serde_json::json!({ "error": "unauthorized" })); + (StatusCode::UNAUTHORIZED, body).into_response() + } + (Ok(()), Err(rejection)) => rejection.into_response(), + (Ok(()), Ok(upgrade)) => { + let max = s.limits.max_frame; + let tracked = s.tracker.clone(); + upgrade + .max_message_size(max) + .max_frame_size(max) + .on_upgrade(move |socket| tracked.track_future(link::run(socket, s, permit))) + } + } +} diff --git a/crates/buzz-relay-gateway/src/link.rs b/crates/buzz-relay-gateway/src/link.rs new file mode 100644 index 00000000000..c0036cfe7c3 --- /dev/null +++ b/crates/buzz-relay-gateway/src/link.rs @@ -0,0 +1,405 @@ +//! One task per link: `hello`, frame dispatch, pings, rotation, idle and +//! backpressure, and the teardown of everything the link opened. + +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use axum::extract::ws::{CloseFrame, Message, WebSocket}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use futures_util::{stream::SplitSink, SinkExt, StreamExt}; +use tokio::{ + sync::{mpsc, mpsc::error::TrySendError, OwnedSemaphorePermit, Semaphore}, + task::{AbortHandle, JoinSet}, + time::{interval_at, sleep_until, timeout, Instant}, +}; +use tokio_util::sync::CancellationToken; + +use crate::{ + frame::{ErrorCode, HelloLimits, HttpFrame, Id, Inbound, Out}, + http_forward, policy, + relay_ws::{Cmd, Socket}, + AppState, +}; + +// Teardown waits this long for the relay sockets, then for the Worker to take the close frame. +const CLOSE_WAIT: Duration = Duration::from_secs(2); +const SHUTDOWN: (u16, &str) = (1012, "gateway restarting"); + +type Queued = (Message, Option); + +/// Milliseconds since `since`, for logs and frames. +pub(crate) fn ms(since: Instant) -> u64 { + u64::try_from(since.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +fn unix_now() -> Duration { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() +} + +/// The link's bounded outbound queue. Nothing waits on it: when it is full the +/// Worker is not reading, and the link closes with 4003. +#[derive(Clone)] +pub(crate) struct Outbox { + tx: mpsc::Sender, + bytes: Arc, + overflow: CancellationToken, +} + +impl Outbox { + pub(crate) fn send(&self, frame: &Out) { + let Ok(text) = serde_json::to_string(frame) else { + return; + }; + let permit = u32::try_from(text.len()) + .ok() + .and_then(|n| self.bytes.clone().try_acquire_many_owned(n).ok()); + let full = match permit { + Some(permit) => matches!( + self.tx.try_send((Message::Text(text.into()), Some(permit))), + Err(TrySendError::Full(_)) + ), + None => true, + }; + if full { + self.overflow.cancel(); + } + } +} + +struct SocketHandle { + tx: mpsc::Sender, + overflow: CancellationToken, +} + +struct Link { + state: Arc, + id: String, + out: Outbox, + draining: bool, + http: JoinSet<()>, + inflight: HashMap, + sockets: HashMap, + socket_tasks: JoinSet<()>, + sockets_closed: CancellationToken, +} + +/// Runs one link until it closes. `_permit` holds its slot under the link cap. +pub(crate) async fn run(socket: WebSocket, state: Arc, _permit: OwnedSemaphorePermit) { + let lim = &state.limits; + let (sink, mut stream) = socket.split(); + let (tx, rx) = mpsc::channel(lim.out_frames); + let out = Outbox { + tx, + bytes: Arc::new(Semaphore::new(lim.out_bytes)), + overflow: CancellationToken::new(), + }; + let mut writer = tokio::spawn(write(sink, rx)); + let now = unix_now().as_secs(); + let rotate_in = lim.link_lifetime.saturating_sub(lim.rotate_before); + let id = uuid::Uuid::new_v4().to_string(); + out.send(&Out::Hello { + v: 1, + link_id: id.clone(), + expires_at: now + lim.link_lifetime.as_secs(), + rotate_at: now + rotate_in.as_secs(), + limits: HelloLimits { + max_request_body: lim.max_request_body, + max_response_body: lim.max_response_body, + max_ws_text: lim.max_ws_text, + max_http_inflight: lim.max_http_inflight, + max_sockets: lim.max_sockets, + }, + }); + tracing::info!(link_id = id, "link opened"); + let opened = Instant::now(); + let mut link = Link { + state: state.clone(), + id, + out, + draining: false, + http: JoinSet::new(), + inflight: HashMap::new(), + sockets: HashMap::new(), + socket_tasks: JoinSet::new(), + sockets_closed: CancellationToken::new(), + }; + let (code, reason) = link + .serve(&mut stream, opened + lim.link_lifetime, opened + rotate_in) + .await; + tracing::info!( + link_id = link.id, + code, + reason, + ms = ms(opened), + "link closing" + ); + // No relay socket outlives its link. + link.http.abort_all(); + link.sockets_closed.cancel(); + let _ = timeout(CLOSE_WAIT, async { + while link.socket_tasks.join_next().await.is_some() {} + }) + .await; + link.socket_tasks.abort_all(); + let close = Message::Close(Some(CloseFrame { + code, + reason: reason.into(), + })); + let _ = timeout(CLOSE_WAIT, link.out.tx.send((close, None))).await; + if timeout(CLOSE_WAIT, &mut writer).await.is_err() { + writer.abort(); + } +} + +async fn write(mut sink: SplitSink, mut rx: mpsc::Receiver) { + while let Some((msg, _bytes)) = rx.recv().await { + let close = matches!(msg, Message::Close(_)); + if sink.send(msg).await.is_err() || close { + break; + } + } +} + +impl Link { + async fn serve( + &mut self, + stream: &mut futures_util::stream::SplitStream, + expires: Instant, + rotate: Instant, + ) -> (u16, &'static str) { + let lim = self.state.limits.clone(); + let mut ping = interval_at(Instant::now() + lim.ping_every, lim.ping_every); + let mut idle = Instant::now() + lim.idle; + let mut rotated = false; + let mut drain_until = None; + loop { + tokio::select! { + msg = stream.next() => { + idle = Instant::now() + lim.idle; + match msg { + Some(Ok(Message::Text(text))) => { + if let Err(close) = self.on_text(text.as_str()) { + return close; + } + } + Some(Ok(Message::Binary(_))) => return (1003, "binary frames are reserved"), + Some(Ok(Message::Close(_))) | None => return (1000, "closed by worker"), + Some(Ok(_)) => {} + // axum's tungstenite is not ours to downcast; its capacity error reads this way. + Some(Err(e)) if e.to_string().contains("Space limit exceeded") => return (1009, "frame too large"), + Some(Err(_)) => return (1002, "protocol error"), + } + } + _ = ping.tick() => { + let ts = u64::try_from(unix_now().as_millis()).unwrap_or(u64::MAX); + self.out.send(&Out::Ping { ts: ts.into() }); + } + _ = sleep_until(rotate), if !rotated => { + rotated = true; + let close_at = (unix_now() + expires.saturating_duration_since(Instant::now())).as_secs(); + self.out.send(&Out::Rotate { close_at }); + } + _ = sleep_until(expires) => return (4001, "rotate"), + _ = sleep_until(idle) => return (4002, "idle"), + _ = self.out.overflow.cancelled() => return (4003, "backpressure"), + _ = self.state.shutdown.cancelled(), if !self.draining => { + self.draining = true; + drain_until = Some(Instant::now() + lim.shutdown_grace); + if self.http.is_empty() { + return SHUTDOWN; + } + } + _ = sleep_until(drain_until.unwrap_or(expires)), if drain_until.is_some() => return SHUTDOWN, + Some(_) = self.http.join_next(), if !self.http.is_empty() => { + self.inflight.retain(|_, task| !task.is_finished()); + if self.draining && self.http.is_empty() { + return SHUTDOWN; + } + } + Some(_) = self.socket_tasks.join_next(), if !self.socket_tasks.is_empty() => { + self.sockets.retain(|_, socket| !socket.tx.is_closed()); + } + } + } + } + + /// Handles one Worker frame. `Err` closes the link with that code. + fn on_text(&mut self, text: &str) -> Result<(), (u16, &'static str)> { + let Ok(frame) = Inbound::parse(text) else { + self.refuse(ErrorCode::BadFrame, None, None); + return Err((1008, "bad frame")); + }; + match frame { + Inbound::Http(f) => { + let id = f.id.clone(); + if let Err(code) = self.http(f) { + self.refuse(code, Some(id), None); + } + } + Inbound::WsOpen { sid, path } => { + if let Err(code) = self.ws_open(sid.clone(), &path) { + self.refuse(code, None, Some(sid)); + } + } + Inbound::WsSend { sid, text } => { + let result = if text.len() > self.state.limits.max_ws_text { + Err(ErrorCode::FrameTooLarge) + } else { + self.to_socket(&sid, Cmd::Send(text)) + }; + if let Err(code) = result { + self.refuse(code, None, Some(sid)); + } + } + Inbound::WsClose { sid, code, reason } => { + let code = code.unwrap_or(1000); + let reason = reason.unwrap_or_default(); + // A close frame allows 1000 or an application code, and a 123-byte reason. + let result = + if (code == 1000 || (3000..=4999).contains(&code)) && reason.len() <= 123 { + self.to_socket(&sid, Cmd::Close(code, reason)) + } else { + Err(ErrorCode::NotAllowed) + }; + if let Err(code) = result { + self.refuse(code, None, Some(sid)); + } + } + Inbound::Ping { ts } => self.out.send(&Out::Pong { ts }), + Inbound::Pong => {} + Inbound::Unknown => self.refuse(ErrorCode::UnknownType, None, None), + } + Ok(()) + } + + fn refuse(&self, code: ErrorCode, id: Option, sid: Option) { + tracing::info!( + link_id = self.id, + id = id.as_ref().map(Id::as_str), + sid = sid.as_ref().map(Id::as_str), + code = ?code, + "frame refused" + ); + self.out.send(&Out::error(code, id, sid)); + } + + fn http(&mut self, f: HttpFrame) -> Result<(), ErrorCode> { + let lim = &self.state.limits; + if self.draining { + return Err(ErrorCode::ShuttingDown); + } + if self.inflight.contains_key(&f.id) { + return Err(ErrorCode::DuplicateId); + } + if self.inflight.len() >= lim.max_http_inflight { + return Err(ErrorCode::Busy); + } + let route = policy::http_route(&f.method, &f.path)?; + let raw_headers = f.headers.as_deref().map(|raw| raw.get()); + let headers = policy::request_headers(route, raw_headers, lim.max_auth_header)?; + let body = match (route.method() == axum::http::Method::POST, f.body_b64) { + (true, Some(b64)) => { + let body = STANDARD.decode(b64).map_err(|_| ErrorCode::BadBase64)?; + if body.len() > lim.max_request_body { + return Err(ErrorCode::BodyTooLarge); + } + Some(body) + } + (false, None) => None, + _ => return Err(ErrorCode::NotAllowed), + }; + let url = self + .state + .config + .relay_http + .join(route.path()) + .map_err(|_| ErrorCode::NotAllowed)?; + let (state, out, link_id, id) = ( + self.state.clone(), + self.out.clone(), + self.id.clone(), + f.id.clone(), + ); + let task = self.http.spawn(async move { + let started = Instant::now(); + let result = http_forward::forward( + &state.client, + route.method(), + url, + headers, + body, + state.limits.max_response_body, + ) + .await; + let (link_id, path, ms) = (link_id.as_str(), route.path(), ms(started)); + out.send(&match result { + Ok(answer) => { + tracing::info!(link_id, id = id.as_str(), path, status = answer.status, ms, "http forwarded"); + let body_b64 = STANDARD.encode(answer.body); + Out::HttpRes { id, status: answer.status, headers: answer.headers, body_b64, ms } + } + Err(code) => { + tracing::info!(link_id, id = id.as_str(), path, code = ?code, ms, "http failed"); + Out::error(code, Some(id), None) + } + }); + }); + self.inflight.insert(f.id, task); + Ok(()) + } + + fn ws_open(&mut self, sid: Id, path: &str) -> Result<(), ErrorCode> { + let lim = &self.state.limits; + if self.draining { + return Err(ErrorCode::ShuttingDown); + } + if self.sockets.contains_key(&sid) { + return Err(ErrorCode::DuplicateId); + } + if self.sockets.len() >= lim.max_sockets { + return Err(ErrorCode::Busy); + } + let url = self + .state + .config + .relay_ws + .join(policy::ws_route(path)?) + .map_err(|_| ErrorCode::NotAllowed)?; + let (tx, cmds) = mpsc::channel(lim.relay_queue); + let overflow = CancellationToken::new(); + let socket = Socket { + link_id: self.id.clone(), + sid: sid.clone(), + url, + out: self.out.clone(), + cmds, + overflow: overflow.clone(), + link_closed: self.sockets_closed.clone(), + shutdown: self.state.shutdown.clone(), + connect_timeout: lim.connect_timeout, + silence: lim.relay_silence, + max_frame: lim.max_relay_frame, + }; + self.socket_tasks.spawn(socket.run()); + self.sockets.insert(sid, SocketHandle { tx, overflow }); + Ok(()) + } + + fn to_socket(&self, sid: &Id, cmd: Cmd) -> Result<(), ErrorCode> { + let socket = self.sockets.get(sid).ok_or(ErrorCode::UnknownSocket)?; + match socket.tx.try_send(cmd) { + Ok(()) => Ok(()), + Err(TrySendError::Closed(_)) => Err(ErrorCode::UnknownSocket), + // The relay is not taking frames: close that socket, not the link. + Err(TrySendError::Full(_)) => { + socket.overflow.cancel(); + Ok(()) + } + } + } +} diff --git a/crates/buzz-relay-gateway/src/main.rs b/crates/buzz-relay-gateway/src/main.rs new file mode 100644 index 00000000000..792d4206d03 --- /dev/null +++ b/crates/buzz-relay-gateway/src/main.rs @@ -0,0 +1,56 @@ +use buzz_relay_gateway::{Config, Limits}; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Workspace builds unify ring and aws-lc-rs; rustls then needs an explicit provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_owned()); + tracing_subscriber::fmt() + .json() + .with_env_filter(buzz_relay_gateway::log_filter(&directives)) + .init(); + let config = match Config::from_env() { + Ok(config) => config, + Err(e) => { + tracing::error!(error = %e, "invalid configuration"); + std::process::exit(1); + } + }; + tracing::info!( + bind = %config.bind_addr, + relay = config.relay_ws.host_str().unwrap_or_default(), + link_url = %config.link_url, + link_pubkey = %config.link_pubkey, + "relay gateway starting" + ); + let listener = TcpListener::bind(config.bind_addr).await?; + let shutdown = CancellationToken::new(); + tokio::spawn({ + let shutdown = shutdown.clone(); + async move { + signal().await; + tracing::info!("shutdown signal received"); + shutdown.cancel(); + } + }); + buzz_relay_gateway::serve(listener, config, Limits::default(), shutdown).await?; + Ok(()) +} + +// The binary is PID 1 in the image, which has no default signal handling. +async fn signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + if let Ok(mut term) = signal(SignalKind::terminate()) { + tokio::select! { + _ = term.recv() => {} + _ = tokio::signal::ctrl_c() => {} + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} diff --git a/crates/buzz-relay-gateway/src/policy.rs b/crates/buzz-relay-gateway/src/policy.rs new file mode 100644 index 00000000000..3cfb75c2b25 --- /dev/null +++ b/crates/buzz-relay-gateway/src/policy.rs @@ -0,0 +1,249 @@ +//! What may reach the relay: an exact route table and closed header sets (design §4). + +use std::collections::BTreeMap; + +use axum::http::{header, HeaderMap, HeaderValue, Method}; +use serde::Deserialize; + +const NIP11: &str = "application/nostr+json"; + +/// A relay HTTP route. The upstream path is always one of these literals. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HttpRoute { + /// `POST /query` + Query, + /// `POST /events` + Events, + /// `POST /count` + Count, + /// `GET /` for the NIP-11 relay information document. + Info, +} + +impl HttpRoute { + /// The exact path sent upstream. + pub fn path(self) -> &'static str { + match self { + Self::Query => "/query", + Self::Events => "/events", + Self::Count => "/count", + Self::Info => "/", + } + } + + /// The upstream method; every POST route carries a body and GET carries none. + pub fn method(self) -> Method { + if self == Self::Info { + Method::GET + } else { + Method::POST + } + } +} + +/// Why a frame may not go upstream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refused { + /// Not in the table, or a header that is not allowed. + NotAllowed, + /// A route reserved for a later phase (the huddle audio path). + NotSupported, +} + +/// Matches a frame's method and path exactly: no normalisation, no query string. +pub fn http_route(method: &str, path: &str) -> Result { + match (method, path) { + ("POST", "/query") => Ok(HttpRoute::Query), + ("POST", "/events") => Ok(HttpRoute::Events), + ("POST", "/count") => Ok(HttpRoute::Count), + ("GET", "/") => Ok(HttpRoute::Info), + _ => Err(Refused::NotAllowed), + } +} + +/// Relay socket paths: `/` only. The huddle audio path is reserved for Phase 2. +pub fn ws_route(path: &str) -> Result<&'static str, Refused> { + if path == "/" { + return Ok("/"); + } + let audio = path + .strip_prefix("/huddle/") + .and_then(|rest| rest.strip_suffix("/audio")) + .is_some_and(|id| id.len() == 36 && uuid::Uuid::try_parse(id).is_ok()); + Err(if audio { + Refused::NotSupported + } else { + Refused::NotAllowed + }) +} + +// A struct, not a map: serde refuses unknown and duplicated keys for it. +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct FrameHeaders { + authorization: Option, + #[serde(rename = "content-type")] + content_type: Option, + accept: Option, +} + +/// Builds the upstream request headers from a frame's raw `headers` object. +/// POST routes need `authorization`; `GET /` needs exactly the NIP-11 `accept`. +pub fn request_headers( + route: HttpRoute, + raw: Option<&str>, + max_auth: usize, +) -> Result { + let h: FrameHeaders = match raw { + Some(raw) => serde_json::from_str(raw).map_err(|_| Refused::NotAllowed)?, + None => FrameHeaders::default(), + }; + let ok = match route { + HttpRoute::Info => { + h.authorization.is_none() + && h.content_type.is_none() + && h.accept.as_deref() == Some(NIP11) + } + _ => h + .authorization + .as_ref() + .is_some_and(|a| a.len() <= max_auth), + }; + if !ok { + return Err(Refused::NotAllowed); + } + let mut out = HeaderMap::new(); + for (name, value) in [ + (header::AUTHORIZATION, h.authorization), + (header::CONTENT_TYPE, h.content_type), + (header::ACCEPT, h.accept), + ] { + if let Some(value) = value { + out.insert( + name, + HeaderValue::from_str(&value).map_err(|_| Refused::NotAllowed)?, + ); + } + } + Ok(out) +} + +/// The relay response headers the Worker sees: `content-type` and `retry-after` only. +pub fn response_headers(headers: &HeaderMap) -> BTreeMap<&'static str, String> { + [ + ("content-type", header::CONTENT_TYPE), + ("retry-after", header::RETRY_AFTER), + ] + .into_iter() + .filter_map(|(key, name)| Some((key, headers.get(name)?.to_str().ok()?.to_owned()))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_table_is_exact() { + assert_eq!(http_route("POST", "/query"), Ok(HttpRoute::Query)); + assert_eq!(http_route("POST", "/events"), Ok(HttpRoute::Events)); + assert_eq!(http_route("POST", "/count"), Ok(HttpRoute::Count)); + assert_eq!(http_route("GET", "/"), Ok(HttpRoute::Info)); + for (m, p) in [ + ("GET", "/query"), + ("PUT", "/query"), + ("post", "/query"), + ("POST", "/QUERY"), + ("POST", "/query/"), + ("POST", "//query"), + ("POST", "/query?x=1"), + ("POST", "/./query"), + ("POST", "/%71uery"), + ("POST", "/"), + ("GET", "/hooks/1"), + ] { + assert_eq!(http_route(m, p), Err(Refused::NotAllowed), "{m} {p}"); + } + assert_eq!(ws_route("/"), Ok("/")); + assert_eq!( + ws_route("/huddle/6f1c2c1e-4b9a-4c71-9a55-0f1d8c3a2b10/audio"), + Err(Refused::NotSupported) + ); + for p in [ + "", + "/query", + "//", + "/?x", + "/huddle/x/audio", + "/huddle/6f1c2c1e4b9a4c719a550f1d8c3a2b10/audio", + ] { + assert_eq!(ws_route(p), Err(Refused::NotAllowed), "{p}"); + } + } + + #[test] + fn request_headers_are_a_closed_set() { + let post = |raw: &str| request_headers(HttpRoute::Query, Some(raw), 16); + let h = post(r#"{"authorization":"Nostr abc","content-type":"application/json"}"#).unwrap(); + assert_eq!(h.len(), 2); + assert_eq!(h[header::AUTHORIZATION], "Nostr abc"); + for bad in [ + r#"{"authorization":"Nostr abc","x-pubkey":"00"}"#, + r#"{"authorization":"Nostr abc","host":"evil.example"}"#, + r#"{"authorization":"Nostr abc","x-forwarded-for":"1.2.3.4"}"#, + r#"{"authorization":"Nostr abc","accept":"a","accept":"b"}"#, + r#"{"authorization":"Nostr abc\r\nx-pubkey: 00"}"#, + r#"{"authorization":"Nostr abc","content-type":"a\nb"}"#, + r#"{"Authorization":"Nostr abc"}"#, + r#"{"authorization":"Nostr 0123456789abc"}"#, + r#"{"content-type":"application/json"}"#, + r#"["authorization"]"#, + ] { + assert_eq!(post(bad), Err(Refused::NotAllowed), "{bad}"); + } + assert_eq!( + request_headers(HttpRoute::Query, None, 16), + Err(Refused::NotAllowed) + ); + let info = |raw: &str| request_headers(HttpRoute::Info, Some(raw), 16); + assert!(info(r#"{"accept":"application/nostr+json"}"#).is_ok()); + assert_eq!( + info(r#"{"accept":"application/json"}"#), + Err(Refused::NotAllowed) + ); + assert_eq!( + info(r#"{"accept":"application/nostr+json","authorization":"x"}"#), + Err(Refused::NotAllowed) + ); + assert_eq!( + request_headers(HttpRoute::Info, None, 16), + Err(Refused::NotAllowed) + ); + } + + #[test] + fn response_headers_keep_two_names() { + let mut h = HeaderMap::new(); + for (name, value) in [ + ("content-type", "application/json"), + ("retry-after", "12"), + ("set-cookie", "a=b"), + ("location", "/elsewhere"), + ("server", "cloudflare"), + ("cf-ray", "1"), + ] { + h.insert( + header::HeaderName::from_static(name), + HeaderValue::from_static(value), + ); + } + let kept = response_headers(&h); + assert_eq!( + kept.into_iter().collect::>(), + [ + ("content-type", "application/json".into()), + ("retry-after", "12".into()) + ] + ); + } +} diff --git a/crates/buzz-relay-gateway/src/relay_ws.rs b/crates/buzz-relay-gateway/src/relay_ws.rs new file mode 100644 index 00000000000..641a9d091de --- /dev/null +++ b/crates/buzz-relay-gateway/src/relay_ws.rs @@ -0,0 +1,207 @@ +//! One task per relay socket. It keeps reading the relay, so the relay's pings +//! are answered at once, and it reports exactly one `ws_closed`. + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use tokio::{ + sync::mpsc, + time::{sleep_until, timeout, Instant}, +}; +use tokio_tungstenite::{ + connect_async_with_config, + tungstenite::{ + client::IntoClientRequest, + http::{header::USER_AGENT, HeaderValue}, + protocol::{CloseFrame, WebSocketConfig}, + Error, Message, + }, +}; +use tokio_util::sync::CancellationToken; +use url::Url; + +use crate::{ + frame::{Id, Out, Side}, + link::{ms, Outbox}, +}; + +const AUTH_PREFIX: &str = r#"["AUTH","#; +const CLOSE_CAP: Duration = Duration::from_secs(1); + +/// A Worker frame for one relay socket. +pub(crate) enum Cmd { + Send(String), + Close(u16, String), +} + +/// One relay socket. The link keeps the sending half of `cmds`. +pub(crate) struct Socket { + pub link_id: String, + pub sid: Id, + pub url: Url, + pub out: Outbox, + pub cmds: mpsc::Receiver, + /// Cancelled when the link could not queue a Worker frame for this socket. + pub overflow: CancellationToken, + /// Cancelled when the link ends. + pub link_closed: CancellationToken, + pub shutdown: CancellationToken, + pub connect_timeout: Duration, + pub silence: Duration, + pub max_frame: usize, +} + +struct Closed { + by: Side, + code: Option, + reason: String, + http_status: Option, +} + +fn by_gateway(code: Option, reason: &str) -> Closed { + Closed { + by: Side::Gateway, + code, + reason: reason.to_owned(), + http_status: None, + } +} + +impl Socket { + pub(crate) async fn run(mut self) { + let opened = Instant::now(); + let closed = self.serve(opened).await; + // Relay and Worker close reasons are frame content, so only gateway reasons are logged. + let reason = (closed.by == Side::Gateway).then_some(closed.reason.as_str()); + tracing::info!( + link_id = self.link_id, + sid = self.sid.as_str(), + by = ?closed.by, + code = closed.code, + reason, + http_status = closed.http_status, + ms = ms(opened), + "relay socket closed" + ); + self.out.send(&Out::WsClosed { + sid: self.sid, + by: closed.by, + code: closed.code, + reason: closed.reason, + http_status: closed.http_status, + }); + } + + fn end(&self) -> (u16, &'static str) { + if self.shutdown.is_cancelled() { + (1001, "shutdown") + } else { + (1000, "link_closed") + } + } + + async fn serve(&mut self, opened: Instant) -> Closed { + let Ok(mut request) = self.url.as_str().into_client_request() else { + return by_gateway(None, "connect_failed"); + }; + let agent = concat!("buzz-relay-gateway/", env!("CARGO_PKG_VERSION")); + request + .headers_mut() + .insert(USER_AGENT, HeaderValue::from_static(agent)); + let config = WebSocketConfig::default() + .max_message_size(Some(self.max_frame)) + .max_frame_size(Some(self.max_frame)); + let connecting = connect_async_with_config(request, Some(config), false); + let connect = timeout(self.connect_timeout, connecting); + let mut ws = tokio::select! { + result = connect => match result { + Err(_) => return by_gateway(None, "connect_timeout"), + Ok(Err(Error::Http(response))) => { + let status = Some(response.status().as_u16()); + return Closed { http_status: status, ..by_gateway(None, "upgrade_refused") }; + } + Ok(Err(_)) => return by_gateway(None, "connect_failed"), + Ok(Ok((ws, _))) => ws, + }, + _ = self.link_closed.cancelled() => return by_gateway(None, self.end().1), + }; + self.out.send(&Out::WsOpened { + sid: self.sid.clone(), + ms: ms(opened), + }); + let (link_id, sid) = (self.link_id.as_str(), self.sid.as_str()); + let mut challenge = None; + let mut silent_at = Instant::now() + self.silence; + let (frame, closed) = loop { + let (code, reason) = tokio::select! { + msg = ws.next() => { + silent_at = Instant::now() + self.silence; + match msg { + Some(Ok(Message::Text(text))) => { + if text.starts_with(AUTH_PREFIX) { + tracing::info!(link_id, sid, auth_challenge_ms = ms(opened), "relay sent AUTH"); + challenge = Some(Instant::now()); + } + self.out.send(&Out::WsMsg { sid: self.sid.clone(), text: text.as_str().to_owned() }); + continue; + } + Some(Ok(Message::Close(frame))) => { + let (code, reason) = frame.map_or((None, String::new()), |f| (Some(f.code.into()), f.reason.as_str().to_owned())); + // tungstenite only queues the close reply; flushing sends it. + let _ = timeout(CLOSE_CAP, ws.flush()).await; + return Closed { by: Side::Relay, code, reason, http_status: None }; + } + Some(Ok(_)) => continue, + Some(Err(Error::Capacity(_))) => (1009, "frame_too_large"), + Some(Err(_)) | None => { + return Closed { by: Side::Relay, code: None, reason: "connection_lost".into(), http_status: None }; + } + } + } + cmd = self.cmds.recv() => match cmd { + Some(Cmd::Send(text)) => { + if let Some(at) = challenge.filter(|_| text.starts_with(AUTH_PREFIX)) { + tracing::info!(link_id, sid, auth_reply_ms = ms(at), "worker answered AUTH"); + challenge = None; + } + // A relay that stops reading stalls this write, so the socket's other ends still apply. + tokio::select! { + sent = ws.send(Message::Text(text.into())) => { + if sent.is_err() { + return Closed { by: Side::Relay, code: None, reason: "connection_lost".into(), http_status: None }; + } + continue; + } + _ = self.overflow.cancelled() => (1000, "backpressure"), + _ = self.link_closed.cancelled() => self.end(), + _ = sleep_until(silent_at) => (1001, "relay_silent"), + } + } + Some(Cmd::Close(code, reason)) => { + let frame = CloseFrame { code: code.into(), reason: reason.clone().into() }; + break (frame, Closed { by: Side::Worker, code: Some(code), reason, http_status: None }); + } + None => self.end(), + }, + _ = self.overflow.cancelled() => (1000, "backpressure"), + _ = self.link_closed.cancelled() => self.end(), + _ = sleep_until(silent_at) => (1001, "relay_silent"), + }; + break ( + CloseFrame { + code: code.into(), + reason: reason.into(), + }, + by_gateway(Some(code), reason), + ); + }; + // The relay's close reply ends the stream; capped so teardown stays bounded. + let _ = timeout(CLOSE_CAP, async { + if ws.close(Some(frame)).await.is_ok() { + while let Some(Ok(_)) = ws.next().await {} + } + }) + .await; + closed + } +} diff --git a/crates/buzz-relay-gateway/tests/common/mod.rs b/crates/buzz-relay-gateway/tests/common/mod.rs new file mode 100644 index 00000000000..4397aa0d491 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/common/mod.rs @@ -0,0 +1,446 @@ +//! Shared harness: the gateway in-process, a mock relay, a mock Worker and a bearer signer. +#![allow(dead_code)] + +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{Arc, Mutex}, + time::Duration, +}; + +use axum::{ + body::{Body, Bytes}, + extract::{ + ws::{rejection::WebSocketUpgradeRejection, Message as AxMessage, WebSocket}, + State, WebSocketUpgrade, + }, + http::{HeaderMap, Method, StatusCode, Uri}, + response::{IntoResponse, Response}, + Router, +}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_relay_gateway::{Config, Limits}; +use futures_util::{SinkExt, StreamExt}; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::{net::TcpListener, task::JoinHandle, time::timeout}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{client::IntoClientRequest, Message}, + MaybeTlsStream, WebSocketStream, +}; +use tokio_util::sync::CancellationToken; + +pub const LINK_URL: &str = "http://gateway.test/v1/link"; + +/// A fresh link bearer signed by `keys`, carrying `content`. +pub fn bearer_with(keys: &Keys, content: &str) -> String { + let nonce = uuid::Uuid::new_v4().to_string(); + let tags = [ + ["u", LINK_URL], + ["method", "GET"], + ["nonce", nonce.as_str()], + ] + .map(|t| Tag::parse(t).unwrap()); + let event = EventBuilder::new(Kind::HttpAuth, content) + .tags(tags) + .sign_with_keys(keys) + .unwrap(); + format!( + "Nostr {}", + STANDARD.encode(serde_json::to_string(&event).unwrap()) + ) +} + +pub fn bearer(keys: &Keys) -> String { + bearer_with(keys, "") +} + +/// Short timers for tests; every other limit stays at its default. +pub fn limits() -> Limits { + Limits { + request_timeout: Duration::from_secs(2), + shutdown_grace: Duration::from_millis(500), + ..Limits::default() + } +} + +pub struct Gateway { + pub addr: SocketAddr, + pub keys: Keys, + pub shutdown: CancellationToken, + pub task: JoinHandle>, +} + +impl Gateway { + pub async fn start(relay_ws: &str, limits: Limits) -> Self { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let config = Config::from_lookup(|name| match name { + "BUZZ_RELAY_GATEWAY_RELAY_URL" => Some(relay_ws.to_owned()), + "BUZZ_RELAY_GATEWAY_LINK_URL" => Some(LINK_URL.to_owned()), + "BUZZ_RELAY_GATEWAY_LINK_PUBKEY" => Some(pubkey.clone()), + _ => None, + }) + .unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let shutdown = CancellationToken::new(); + let task = tokio::spawn(buzz_relay_gateway::serve( + listener, + config, + limits, + shutdown.clone(), + )); + Self { + addr, + keys, + shutdown, + task, + } + } + + pub fn url(&self, path: &str) -> String { + format!("http://{}{path}", self.addr) + } + + /// A Worker on a fresh link, past `hello`. + pub async fn worker(&self) -> Worker { + Worker::connect(self, &bearer(&self.keys)).await.unwrap() + } +} + +/// The mock Worker: one link, frames as JSON values. +pub struct Worker { + pub ws: WebSocketStream>, + pub hello: Value, +} + +pub enum Next { + Frame(Value), + Closed(Option), +} + +impl Worker { + pub async fn connect( + gw: &Gateway, + bearer: &str, + ) -> Result { + let mut request = format!("ws://{}/v1/link", gw.addr).into_client_request()?; + request + .headers_mut() + .insert("authorization", bearer.parse().unwrap()); + let (ws, _) = connect_async(request).await?; + let mut worker = Self { + ws, + hello: Value::Null, + }; + worker.hello = worker.frame().await; + assert_eq!(worker.hello["type"], "hello"); + Ok(worker) + } + + pub async fn send(&mut self, frame: Value) { + self.ws + .send(Message::text(frame.to_string())) + .await + .unwrap(); + } + + /// The next frame other than a gateway `ping`. + pub async fn next(&mut self) -> Next { + loop { + let msg = timeout(Duration::from_secs(10), self.ws.next()) + .await + .expect("no frame within 10 s"); + match msg { + Some(Ok(Message::Text(text))) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + if frame["type"] != "ping" { + return Next::Frame(frame); + } + } + Some(Ok(Message::Close(frame))) => { + return Next::Closed(frame.map(|f| f.code.into())) + } + Some(Ok(_)) => {} + Some(Err(_)) | None => return Next::Closed(None), + } + } + } + + pub async fn frame(&mut self) -> Value { + match self.next().await { + Next::Frame(frame) => frame, + Next::Closed(code) => panic!("link closed: {code:?}"), + } + } + + /// Reads until the link closes; returns the close code. + pub async fn closed(&mut self) -> Option { + loop { + if let Next::Closed(code) = self.next().await { + return code; + } + } + } + + pub async fn http( + &mut self, + id: &str, + method: &str, + path: &str, + headers: Value, + body: Option<&[u8]>, + ) -> Value { + let mut frame = + json!({ "type": "http", "id": id, "method": method, "path": path, "headers": headers }); + if let Some(body) = body { + frame["body_b64"] = STANDARD.encode(body).into(); + } + self.send(frame).await; + self.frame().await + } + + pub async fn post(&mut self, id: &str, path: &str, body: &[u8]) -> Value { + self.http( + id, + "POST", + path, + json!({ "authorization": "Nostr test" }), + Some(body), + ) + .await + } + + /// Opens relay socket `sid` and returns the relay's AUTH challenge. + pub async fn open(&mut self, sid: &str) -> String { + self.send(json!({"type": "ws_open", "sid": sid, "path": "/"})) + .await; + let opened = self.frame().await; + assert_eq!( + (opened["type"].as_str(), opened["sid"].as_str()), + (Some("ws_opened"), Some(sid)) + ); + let msg = self.frame().await; + assert_eq!( + (msg["type"].as_str(), msg["sid"].as_str()), + (Some("ws_msg"), Some(sid)) + ); + let auth: Value = serde_json::from_str(msg["text"].as_str().unwrap()).unwrap(); + assert_eq!(auth[0], "AUTH"); + auth[1].as_str().unwrap().to_owned() + } + + /// Sends `text` on relay socket `sid` without waiting for an answer. + pub async fn ws_send(&mut self, sid: &str, text: &str) { + self.send(json!({"type": "ws_send", "sid": sid, "text": text})) + .await; + } + + /// Sends `text` on socket `sid` and returns the relay's next frame on the link. + pub async fn say(&mut self, sid: &str, text: Value) -> Value { + self.ws_send(sid, &text.to_string()).await; + let msg = self.frame().await; + assert_eq!(msg["type"], "ws_msg", "{msg}"); + serde_json::from_str(msg["text"].as_str().unwrap()).unwrap() + } +} + +/// A mock relay, a gateway pointed at it, and a Worker on a fresh link. +pub async fn setup(limits: Limits) -> (Relay, Gateway, Worker) { + let relay = Relay::start().await; + let gw = Gateway::start(&relay.ws_url(), limits).await; + let w = gw.worker().await; + (relay, gw, w) +} + +pub fn body_of(frame: &Value) -> Vec { + STANDARD + .decode(frame["body_b64"].as_str().unwrap()) + .unwrap() +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// How the mock relay answers HTTP (and socket upgrades, for `Reply`). +#[derive(Clone, Default)] +pub enum Mode { + /// Report the hash, length and headers of what arrived. + #[default] + Echo, + /// This status and body. + Reply(u16, Vec), + /// 302 to `/elsewhere`. + Redirect, + /// Never answer. + Stall, + /// Send part of a body, then drop the connection. + CloseMid, +} + +#[derive(Default)] +pub struct RelayState { + pub mode: Mutex, + pub hits: Mutex>, + pub open_sockets: Mutex, + pub socket_closes: Mutex>>, + pub url: Mutex, +} + +/// The mock relay: HTTP routes on every path and NIP-42 sockets on `/`. +pub struct Relay { + pub addr: SocketAddr, + pub state: Arc, +} + +impl Relay { + pub async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let state = Arc::new(RelayState::default()); + *state.url.lock().unwrap() = format!("ws://{addr}"); + let app = Router::new().fallback(handle).with_state(state.clone()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Self { addr, state } + } + + pub fn ws_url(&self) -> String { + format!("ws://{}", self.addr) + } + + pub fn set(&self, mode: Mode) { + *self.state.mode.lock().unwrap() = mode; + } + + pub fn hits(&self, path: &str) -> usize { + self.state + .hits + .lock() + .unwrap() + .get(path) + .copied() + .unwrap_or(0) + } + + pub fn open_sockets(&self) -> usize { + *self.state.open_sockets.lock().unwrap() + } + + pub fn socket_closes(&self) -> Vec> { + self.state.socket_closes.lock().unwrap().clone() + } +} + +async fn handle( + State(s): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + upgrade: Result, + body: Bytes, +) -> Response { + let path = uri.path().to_owned(); + *s.hits.lock().unwrap().entry(path.clone()).or_default() += 1; + let mode = s.mode.lock().unwrap().clone(); + if let (Ok(upgrade), Mode::Echo) = (upgrade, &mode) { + return upgrade.on_upgrade(move |ws| relay_socket(ws, s)); + } + match mode { + Mode::Echo if method == Method::GET => { + json!({ "name": "mock relay", "accept": headers["accept"].to_str().unwrap() }) + .to_string() + .into_response() + } + Mode::Echo => { + let seen: serde_json::Map = headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").into())) + .collect(); + let url = format!( + "http://{}{path}", + s.url.lock().unwrap().trim_start_matches("ws://") + ); + let nip98 = headers + .get("authorization") + .and_then(|v| v.to_str().ok()?.strip_prefix("Nostr ").map(str::to_owned)) + .and_then(|b64| String::from_utf8(STANDARD.decode(b64).ok()?).ok()) + .is_some_and(|event| { + buzz_auth::verify_nip98_event(&event, &url, "POST", Some(&body)).is_ok() + }); + let echo = json!({ "sha256": sha256_hex(&body), "len": body.len(), "headers": seen, "nip98": nip98 }); + ([("content-type", "application/json")], echo.to_string()).into_response() + } + Mode::Reply(status, body) => ( + StatusCode::from_u16(status).unwrap(), + [("set-cookie", "a=b")], + body, + ) + .into_response(), + Mode::Redirect => (StatusCode::FOUND, [("location", "/elsewhere")]).into_response(), + Mode::Stall => { + tokio::time::sleep(Duration::from_secs(30)).await; + StatusCode::OK.into_response() + } + Mode::CloseMid => { + let chunks = [ + Ok(Bytes::from_static(b"partial")), + Err(std::io::Error::other("cut")), + ]; + Body::from_stream(futures_util::stream::iter(chunks)).into_response() + } + } +} + +/// A relay socket: AUTH challenge first, then commands from the test. +async fn relay_socket(mut ws: WebSocket, s: Arc) { + *s.open_sockets.lock().unwrap() += 1; + let challenge = buzz_auth::generate_challenge(); + let relay_url = s.url.lock().unwrap().clone(); + let reply = |v: Value| AxMessage::text(v.to_string()); + let _ = ws.send(reply(json!(["AUTH", challenge]))).await; + while let Some(Ok(msg)) = ws.recv().await { + let AxMessage::Text(text) = msg else { + if let AxMessage::Close(frame) = msg { + s.socket_closes.lock().unwrap().push(frame.map(|f| f.code)); + break; + } + continue; + }; + let cmd: Value = serde_json::from_str(&text).unwrap_or_default(); + let out = match cmd[0].as_str() { + Some("AUTH") => { + let event: nostr::Event = serde_json::from_value(cmd[1].clone()).unwrap(); + let ok = buzz_auth::verify_nip42_event(&event, &challenge, &relay_url).is_ok(); + vec![reply(json!(["OK", event.id, ok, ""]))] + } + Some("REQ") => vec![reply(json!(["EOSE", cmd[1]]))], + Some("CLOSE_WITH") => { + let code = cmd[1].as_u64().unwrap() as u16; + let frame = axum::extract::ws::CloseFrame { + code, + reason: "relay restarting".into(), + }; + vec![AxMessage::Close(Some(frame))] + } + Some("SEND") => { + let (n, size) = (cmd[1].as_u64().unwrap(), cmd[2].as_u64().unwrap() as usize); + (0..n).map(|_| AxMessage::text("x".repeat(size))).collect() + } + Some("SILENT") => { + tokio::time::sleep(Duration::from_secs(30)).await; + vec![] + } + _ => vec![], + }; + for msg in out { + if ws.send(msg).await.is_err() { + break; + } + } + } + *s.open_sockets.lock().unwrap() -= 1; +} diff --git a/crates/buzz-relay-gateway/tests/http.rs b/crates/buzz-relay-gateway/tests/http.rs new file mode 100644 index 00000000000..219a0486e63 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/http.rs @@ -0,0 +1,246 @@ +mod common; + +use std::time::Duration; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_relay_gateway::Limits; +use common::{body_of, limits, setup, sha256_hex, Gateway, Mode}; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; + +/// A POST /query frame, for requests whose answer the test reads later. +fn query(id: &str) -> Value { + json!({"type":"http","id":id,"method":"POST","path":"/query","headers":{"authorization":"x"},"body_b64":""}) +} + +/// Deterministic bytes that are not valid UTF-8. +fn noise(len: usize) -> Vec { + let mut x: u32 = 0x9e37_79b9; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 24) as u8 | 0x80 + }) + .collect() +} + +#[tokio::test] +async fn bodies_and_headers_arrive_untouched() { + let (relay, _gw, mut w) = setup(limits()).await; + let odd_json = b" [ {\"kinds\" :[0] ,\n\t\"limit\":1 } ] "; + for (i, body) in [noise(64 << 10), odd_json.to_vec()].iter().enumerate() { + let res = w.post(&format!("b{i}"), "/events", body).await; + assert_eq!( + (res["type"].as_str(), res["status"].as_u64()), + (Some("http_res"), Some(200)) + ); + let echo: Value = serde_json::from_slice(&body_of(&res)).unwrap(); + assert_eq!(echo["sha256"], sha256_hex(body)); + assert_eq!(echo["len"], body.len()); + let seen = echo["headers"].as_object().unwrap(); + assert_eq!(seen["host"], relay.addr.to_string()); + assert_eq!( + seen["user-agent"], + concat!("buzz-relay-gateway/", env!("CARGO_PKG_VERSION")) + ); + assert_eq!(seen["authorization"], "Nostr test"); + for name in seen.keys() { + let banned = name.starts_with("x-forwarded") + || [ + "forwarded", + "x-real-ip", + "x-pubkey", + "accept-encoding", + "cookie", + "origin", + ] + .contains(&name.as_str()); + assert!(!banned, "{name} reached the relay"); + } + assert_eq!(res["headers"], json!({"content-type": "application/json"})); + } + assert_eq!(relay.hits("/events"), 2); +} + +#[tokio::test] +async fn response_bytes_come_back_identical() { + let (relay, _gw, mut w) = setup(limits()).await; + let bytes = noise(100_000); + relay.set(Mode::Reply(200, bytes.clone())); + let res = w.post("r", "/query", b"[]").await; + assert_eq!(body_of(&res), bytes); + // Only content-type and retry-after come back; set-cookie stays at the gateway. + assert_eq!( + res["headers"], + json!({"content-type": "application/octet-stream"}) + ); +} + +#[tokio::test] +async fn a_signed_nip98_request_still_verifies_at_the_relay() { + let (relay, _gw, mut w) = setup(limits()).await; + let keys = Keys::generate(); + let body = br#"[{"kinds":[9],"limit":5}]"#; + let url = format!("http://{}/query", relay.addr); + let tags = [ + ["u", url.as_str()], + ["method", "POST"], + ["payload", &sha256_hex(body)], + ] + .map(|t| Tag::parse(t).unwrap()); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let auth = format!( + "Nostr {}", + STANDARD.encode(serde_json::to_string(&event).unwrap()) + ); + let headers = json!({"authorization": auth, "content-type": "application/json", "accept": "application/json"}); + let res = w.http("n", "POST", "/query", headers, Some(body)).await; + let echo: Value = serde_json::from_slice(&body_of(&res)).unwrap(); + assert_eq!(echo["nip98"], true); +} + +#[tokio::test] +async fn relay_answers_pass_through_once() { + let (relay, _gw, mut w) = setup(limits()).await; + let limited = br#"{"error":"rate-limited: quota exceeded; retry in 12s"}"#.to_vec(); + relay.set(Mode::Reply(429, limited.clone())); + let res = w.post("a", "/query", b"[]").await; + assert_eq!(res["status"], 429); + assert_eq!(body_of(&res), limited); + relay.set(Mode::Reply(500, b"boom".to_vec())); + assert_eq!(w.post("b", "/count", b"[]").await["status"], 500); + relay.set(Mode::CloseMid); + let res = w.post("c", "/events", b"{}").await; + assert_eq!(res["code"], "upstream_connect"); + relay.set(Mode::Redirect); + let res = w.post("d", "/events", b"{}").await; + assert_eq!( + (res["status"].as_u64(), &res["headers"]), + (Some(302), &json!({})) + ); + // Every request reached the relay exactly once, and the redirect was not followed. + assert_eq!(relay.hits("/query"), 1); + assert_eq!(relay.hits("/count"), 1); + assert_eq!(relay.hits("/events"), 2); + assert_eq!(relay.hits("/elsewhere"), 0); +} + +#[tokio::test] +async fn size_limits_hold_both_ways() { + let (relay, _gw, mut w) = setup(limits()).await; + let res = w.post("big", "/events", &vec![b'a'; (1 << 20) + 1]).await; + assert_eq!(res["code"], "body_too_large"); + assert_eq!(relay.hits("/events"), 0); + relay.set(Mode::Reply(200, vec![b'a'; (4 << 20) + 1])); + assert_eq!( + w.post("huge", "/query", b"[]").await["code"], + "response_too_large" + ); +} + +#[tokio::test] +async fn frames_outside_the_table_are_refused() { + let (relay, _gw, mut w) = setup(limits()).await; + let auth = json!({"authorization": "Nostr test"}); + let cases = [ + ( + json!({"type":"http","id":"1","method":"POST","path":"/query?x=1","headers":auth,"body_b64":""}), + "not_allowed", + ), + ( + json!({"type":"http","id":"2","method":"POST","path":"/query","headers":{"authorization":"x","x-pubkey":"00"},"body_b64":""}), + "not_allowed", + ), + ( + json!({"type":"http","id":"3","method":"POST","path":"/query","headers":auth}), + "not_allowed", + ), + ( + json!({"type":"http","id":"4","method":"POST","path":"/query","headers":auth,"body_b64":"!!"}), + "bad_base64", + ), + ( + json!({"type":"http","id":"5","method":"GET","path":"/","headers":{"accept":"text/html"}}), + "not_allowed", + ), + ]; + for (frame, code) in cases { + let id = frame["id"].clone(); + w.send(frame).await; + let res = w.frame().await; + assert_eq!((&res["id"], res["code"].as_str()), (&id, Some(code))); + } + assert_eq!(relay.hits("/query") + relay.hits("/"), 0); + let nip11 = json!({"accept": "application/nostr+json"}); + let info = w.http("i", "GET", "/", nip11, None).await; + let doc: Value = serde_json::from_slice(&body_of(&info)).unwrap(); + assert_eq!(doc["accept"], "application/nostr+json"); +} + +#[tokio::test] +async fn in_flight_cap_duplicates_and_timeouts() { + let quick = Limits { + request_timeout: Duration::from_millis(800), + ..limits() + }; + let (relay, _gw, mut w) = setup(quick).await; + relay.set(Mode::Stall); + for i in 0..16 { + w.send(query(&format!("s{i}"))).await; + } + w.send(query("s0")).await; + assert_eq!(w.frame().await["code"], "duplicate_id"); + assert_eq!(w.post("s16", "/query", b"").await["code"], "busy"); + for _ in 0..16 { + assert_eq!(w.frame().await["code"], "upstream_timeout"); + } + relay.set(Mode::Echo); + assert_eq!(w.post("after", "/query", b"[]").await["status"], 200); +} + +#[tokio::test] +async fn a_handshake_that_never_ends_is_upstream_connect() { + // Takes the TCP connection and never answers, so the TLS handshake stalls. + let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = silent.local_addr().unwrap(); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((tcp, _)) = silent.accept().await { + held.push(tcp); + } + }); + let quick = Limits { + connect_timeout: Duration::from_millis(300), + ..limits() + }; + let gw = Gateway::start(&format!("wss://{addr}"), quick).await; + let mut w = gw.worker().await; + let res = w.post("c", "/query", b"[]").await; + assert_eq!(res["code"], "upstream_connect", "{res}"); +} + +#[tokio::test] +async fn shutdown_drains_in_flight_requests() { + let (relay, gw, mut w) = setup(limits()).await; + relay.set(Mode::Stall); + w.send(query("slow")).await; + tokio::time::sleep(Duration::from_millis(100)).await; + gw.shutdown.cancel(); + let health = reqwest::get(gw.url("/healthz")).await.unwrap(); + assert_eq!(health.status(), 503); + assert_eq!( + w.post("late", "/query", b"[]").await["code"], + "shutting_down" + ); + assert_eq!(w.closed().await, Some(1012)); + tokio::time::timeout(Duration::from_secs(5), gw.task) + .await + .unwrap() + .unwrap() + .unwrap(); +} diff --git a/crates/buzz-relay-gateway/tests/link.rs b/crates/buzz-relay-gateway/tests/link.rs new file mode 100644 index 00000000000..4b5a699bc12 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/link.rs @@ -0,0 +1,185 @@ +mod common; + +use std::time::Duration; + +use buzz_relay_gateway::Limits; +use common::{bearer, limits, Gateway, Worker}; +use futures_util::SinkExt; +use nostr::Keys; +use serde_json::json; +use tokio_tungstenite::tungstenite::{Error, Message}; + +// Nothing listens on port 9: link control never reaches the relay. +const NO_RELAY: &str = "ws://127.0.0.1:9"; + +#[tokio::test] +async fn bearer_gate_is_single_use_and_fails_closed() { + let gw = Gateway::start( + NO_RELAY, + Limits { + replay_entries: 1, + ..limits() + }, + ) + .await; + let http = reqwest::Client::new(); + let link = |auth: Option<&str>| { + let req = http.get(gw.url("/v1/link")); + let req = match auth { + Some(auth) => req.header("authorization", auth), + None => req, + }; + async move { req.send().await.unwrap() } + }; + let none = link(None).await; + assert_eq!(none.status(), 401); + assert_eq!(none.text().await.unwrap(), r#"{"error":"unauthorized"}"#); + assert_eq!(link(Some(&bearer(&Keys::generate()))).await.status(), 401); + // Refused bearers used no replay slot, so the one-slot cache still admits this one; + // it then fails only the upgrade, because this is a plain GET. + let good = bearer(&gw.keys); + assert_eq!(link(Some(&good)).await.status(), 400); + assert_eq!(link(Some(&good)).await.status(), 401); + assert_eq!(link(Some(&bearer(&gw.keys))).await.status(), 503); + + let health = http.get(gw.url("/healthz")).send().await.unwrap(); + assert_eq!(health.status(), 200); + assert_eq!(health.text().await.unwrap(), "ok"); + assert_eq!( + http.get(gw.url("/v1/other")).send().await.unwrap().status(), + 404 + ); + + gw.shutdown.cancel(); + gw.task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn bad_bearers_get_no_upgrade() { + let gw = Gateway::start(NO_RELAY, limits()).await; + for bearer in [bearer(&Keys::generate()), "Nostr x".to_owned()] { + let Err(Error::Http(response)) = Worker::connect(&gw, &bearer).await else { + panic!("upgraded with a bad bearer"); + }; + assert_eq!(response.status(), 401); + } +} + +#[tokio::test] +async fn hello_then_ping_pong() { + let gw = Gateway::start(NO_RELAY, limits()).await; + let mut w = gw.worker().await; + let hello = &w.hello; + assert_eq!(hello["v"], 1); + assert_eq!( + hello["expires_at"].as_u64().unwrap() - hello["rotate_at"].as_u64().unwrap(), + 300 + ); + assert_eq!( + hello["limits"], + json!({"max_request_body":1048576,"max_response_body":4194304,"max_ws_text":524288, + "max_http_inflight":16,"max_sockets":8}) + ); + w.send(json!({"type":"ping","ts":1793149200123u64})).await; + assert_eq!( + w.frame().await, + json!({"type":"pong","ts":1793149200123u64}) + ); + w.send(json!({"type":"later","x":1})).await; + assert_eq!(w.frame().await["code"], "unknown_type"); + w.send(json!({"type":"ping"})).await; + assert_eq!(w.frame().await, json!({"type":"pong"})); +} + +#[tokio::test] +async fn gateway_pings_and_silence_closes_4002() { + let quick = Limits { + ping_every: Duration::from_millis(100), + idle: Duration::from_millis(600), + ..limits() + }; + let gw = Gateway::start(NO_RELAY, quick).await; + let mut w = gw.worker().await; + let ping = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(Ok(Message::Text(text))) = futures_util::StreamExt::next(&mut w.ws).await { + if text.contains(r#""type":"ping""#) { + return; + } + } + } + }); + ping.await.unwrap(); + assert_eq!(w.closed().await, Some(4002)); +} + +#[tokio::test] +async fn rotate_then_close_4001() { + let short = Limits { + link_lifetime: Duration::from_secs(2), + rotate_before: Duration::from_secs(1), + ..limits() + }; + let gw = Gateway::start(NO_RELAY, short).await; + let mut w = gw.worker().await; + let rotate = w.frame().await; + assert_eq!(rotate["type"], "rotate"); + assert_eq!(rotate["close_at"], w.hello["expires_at"]); + assert_eq!(w.closed().await, Some(4001)); +} + +#[tokio::test] +async fn fifth_link_gets_503() { + let gw = Gateway::start(NO_RELAY, limits()).await; + let mut links = Vec::new(); + for _ in 0..4 { + links.push(gw.worker().await); + } + let Err(Error::Http(response)) = Worker::connect(&gw, &bearer(&gw.keys)).await else { + panic!("a fifth link opened"); + }; + assert_eq!(response.status(), 503); + links.pop().unwrap().ws.close(None).await.unwrap(); + // The freed slot is usable again once that link has torn down. + tokio::time::sleep(Duration::from_millis(300)).await; + gw.worker().await; +} + +#[tokio::test] +async fn protocol_violations_close_the_link() { + let gw = Gateway::start( + NO_RELAY, + Limits { + max_frame: 1024, + ..limits() + }, + ) + .await; + let mut w = gw.worker().await; + w.ws.send(Message::Binary(vec![1, 2, 3].into())) + .await + .unwrap(); + assert_eq!(w.closed().await, Some(1003)); + + let mut w = gw.worker().await; + w.ws.send(Message::text("not json")).await.unwrap(); + assert_eq!(w.frame().await["code"], "bad_frame"); + assert_eq!(w.closed().await, Some(1008)); + + let mut w = gw.worker().await; + w.ws.send(Message::text("x".repeat(2048))).await.unwrap(); + assert_eq!(w.closed().await, Some(1009)); +} + +#[tokio::test] +async fn shutdown_closes_links_1012_and_serve_returns() { + let gw = Gateway::start(NO_RELAY, limits()).await; + let mut w = gw.worker().await; + gw.shutdown.cancel(); + assert_eq!(w.closed().await, Some(1012)); + tokio::time::timeout(Duration::from_secs(5), gw.task) + .await + .unwrap() + .unwrap() + .unwrap(); +} diff --git a/crates/buzz-relay-gateway/tests/logs.rs b/crates/buzz-relay-gateway/tests/logs.rs new file mode 100644 index 00000000000..f3558172d63 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/logs.rs @@ -0,0 +1,72 @@ +//! Its own test binary, because it installs the global subscriber the way +//! main.rs does: fmt().init() also bridges `log` records, which is how +//! tungstenite and reqwest log. +mod common; + +use std::{ + io::Write, + sync::{Arc, Mutex}, +}; + +use common::{bearer_with, limits, Gateway, Relay, Worker}; +use nostr::{EventBuilder, Keys, RelayUrl}; +use serde_json::json; + +#[derive(Clone, Default)] +struct Capture(Arc>>); + +impl Write for Capture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[tokio::test] +async fn logs_never_carry_bearers_bodies_or_frames() { + const MARK: &str = "MARK7f3a"; + let capture = Capture::default(); + let writer = capture.clone(); + // RUST_LOG=trace: the most anyone can turn on. + tracing_subscriber::fmt() + .json() + .with_env_filter(buzz_relay_gateway::log_filter("trace")) + .with_writer(move || writer.clone()) + .init(); + + let relay = Relay::start().await; + let gw = Gateway::start(&relay.ws_url(), limits()).await; + let mut w = Worker::connect(&gw, &bearer_with(&gw.keys, MARK)) + .await + .unwrap(); + let res = w + .http( + "h", + "POST", + "/query", + json!({"authorization": format!("Nostr {MARK}")}), + Some(MARK.as_bytes()), + ) + .await; + assert_eq!(res["status"], 200); + let challenge = w.open("s").await; + let url = RelayUrl::parse(&relay.ws_url()).unwrap(); + let event = EventBuilder::auth(challenge, url) + .sign_with_keys(&Keys::generate()) + .unwrap(); + w.say("s", json!(["AUTH", event, MARK])).await; + w.say("s", json!(["REQ", MARK])).await; + w.send(json!({"type":"ws_close","sid":"s","reason":MARK})) + .await; + w.frame().await; + + let logs = String::from_utf8(capture.0.lock().unwrap().clone()).unwrap(); + assert!(logs.contains("auth_reply_ms"), "the AUTH timing was logged"); + // reqwest logs through `log`, so this line shows the bridge is live. + assert!(logs.contains("starting new connection"), "no `log` records"); + assert!(!logs.contains(MARK), "marker leaked into logs"); + assert!(!logs.contains("Nostr "), "a bearer leaked into logs"); +} diff --git a/crates/buzz-relay-gateway/tests/serve.rs b/crates/buzz-relay-gateway/tests/serve.rs new file mode 100644 index 00000000000..f682f365bc0 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/serve.rs @@ -0,0 +1,21 @@ +mod common; + +use std::time::Duration; + +use buzz_relay_gateway::Limits; +use common::Gateway; +use tokio::{io::AsyncWriteExt, net::TcpStream, time::timeout}; + +#[tokio::test] +async fn a_half_sent_request_does_not_hold_up_shutdown() { + let gw = Gateway::start("ws://127.0.0.1:9", Limits::default()).await; + let mut tcp = TcpStream::connect(gw.addr).await.unwrap(); + // No blank line after the headers: hyper keeps waiting for the rest. + tcp.write_all(b"GET /healthz HTTP/1.1\r\nhost: x\r\n") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + gw.shutdown.cancel(); + let served = timeout(Duration::from_secs(5), gw.task).await; + assert!(matches!(served, Ok(Ok(Ok(())))), "serve did not return"); +} diff --git a/crates/buzz-relay-gateway/tests/ws.rs b/crates/buzz-relay-gateway/tests/ws.rs new file mode 100644 index 00000000000..71f246e1c5a --- /dev/null +++ b/crates/buzz-relay-gateway/tests/ws.rs @@ -0,0 +1,194 @@ +mod common; + +use std::time::Duration; + +use buzz_relay_gateway::Limits; +use common::{limits, setup, Relay, Worker}; +use nostr::{EventBuilder, Keys, RelayUrl}; +use serde_json::json; + +const AUDIO: &str = "/huddle/6f1c2c1e-4b9a-4c71-9a55-0f1d8c3a2b10/audio"; + +async fn authenticate(w: &mut Worker, relay: &Relay, sid: &str) { + let challenge = w.open(sid).await; + let url = RelayUrl::parse(&relay.ws_url()).unwrap(); + let event = EventBuilder::auth(challenge, url) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let ok = w.say(sid, json!(["AUTH", event])).await; + assert_eq!(ok, json!(["OK", event.id, true, ""])); +} + +async fn wait_for(what: &str, check: impl Fn() -> bool) { + for _ in 0..100 { + if check() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("timed out waiting for {what}"); +} + +#[tokio::test] +async fn nip42_auth_and_subscriptions_pass_through() { + let (relay, _gw, mut w) = setup(limits()).await; + authenticate(&mut w, &relay, "s1").await; + let eose = w.say("s1", json!(["REQ", "sub-1", {"kinds": [9]}])).await; + assert_eq!(eose, json!(["EOSE", "sub-1"])); +} + +#[tokio::test] +async fn closes_reach_the_other_side() { + let (relay, _gw, mut w) = setup(limits()).await; + w.open("r").await; + w.ws_send("r", r#"["CLOSE_WITH",1012]"#).await; + assert_eq!( + w.frame().await, + json!({"type":"ws_closed","sid":"r","by":"relay","code":1012,"reason":"relay restarting"}) + ); + // RFC 6455 5.5.1: the relay gets its close echoed back. + wait_for("the close echo", || { + relay.socket_closes().contains(&Some(1012)) + }) + .await; + w.open("w").await; + w.send(json!({"type":"ws_close","sid":"w","code":4000,"reason":"done"})) + .await; + assert_eq!( + w.frame().await, + json!({"type":"ws_closed","sid":"w","by":"worker","code":4000,"reason":"done"}) + ); + wait_for("close 4000", || relay.socket_closes().contains(&Some(4000))).await; + // A closed socket is gone, and its sid can be opened again. + w.ws_send("w", "[]").await; + assert_eq!(w.frame().await["code"], "unknown_socket"); + tokio::time::sleep(Duration::from_millis(100)).await; + w.open("w").await; +} + +#[tokio::test] +async fn closing_the_link_closes_every_relay_socket() { + let (relay, _gw, mut w) = setup(limits()).await; + for sid in ["a", "b", "c"] { + w.open(sid).await; + } + assert_eq!(relay.open_sockets(), 3); + w.ws.close(None).await.unwrap(); + wait_for("every relay socket to close", || relay.open_sockets() == 0).await; +} + +#[tokio::test] +async fn socket_limits() { + let (relay, _gw, mut w) = setup(Limits { + max_ws_text: 64, + ..limits() + }) + .await; + for i in 0..8 { + w.open(&format!("s{i}")).await; + } + w.send(json!({"type":"ws_open","sid":"s8","path":"/"})) + .await; + assert_eq!( + w.frame().await, + json!({"type":"error","sid":"s8","code":"busy","message":"too many requests or sockets in flight"}) + ); + w.send(json!({"type":"ws_open","sid":"s0","path":"/"})) + .await; + assert_eq!(w.frame().await["code"], "duplicate_id"); + w.ws_send("s0", &"x".repeat(65)).await; + assert_eq!(w.frame().await["code"], "frame_too_large"); + // The socket stays usable after a refused frame. + assert_eq!(w.say("s0", json!(["REQ", "q"])).await, json!(["EOSE", "q"])); + for (path, code) in [(AUDIO, "not_supported"), ("/x", "not_allowed")] { + w.send(json!({"type":"ws_close","sid":"s7"})).await; + assert_eq!(w.frame().await["by"], "worker"); + tokio::time::sleep(Duration::from_millis(100)).await; + w.send(json!({"type":"ws_open","sid":"s7","path":path})) + .await; + assert_eq!(w.frame().await["code"], code); + w.open("s7").await; + } + assert_eq!(relay.hits("/x"), 0); +} + +#[tokio::test] +async fn refused_and_failed_opens_report_ws_closed() { + let (relay, _gw, mut w) = setup(limits()).await; + relay.set(common::Mode::Reply(503, b"busy".to_vec())); + w.send(json!({"type":"ws_open","sid":"x","path":"/"})).await; + assert_eq!( + w.frame().await, + json!({"type":"ws_closed","sid":"x","by":"gateway","code":null,"reason":"upgrade_refused","http_status":503}) + ); +} + +#[tokio::test] +async fn relay_side_limits_close_one_socket() { + let quick = Limits { + max_relay_frame: 1024, + relay_silence: Duration::from_millis(500), + ..limits() + }; + let (_relay, _gw, mut w) = setup(quick).await; + w.open("big").await; + w.ws_send("big", r#"["SEND",1,2048]"#).await; + let closed = w.frame().await; + assert_eq!(closed["by"], "gateway"); + assert_eq!(closed["reason"], "frame_too_large"); + w.open("quiet").await; + w.ws_send("quiet", r#"["SILENT"]"#).await; + assert_eq!(w.frame().await["reason"], "relay_silent"); +} + +#[tokio::test] +async fn a_relay_that_stops_reading_closes_that_socket() { + // A queue that never fills, so only the silence timer can end the socket. + let stalled = Limits { + relay_queue: 1024, + relay_silence: Duration::from_secs(5), + ..limits() + }; + let (_relay, _gw, mut w) = setup(stalled).await; + w.open("stuck").await; + // The mock relay stops reading and sending; the write stalls once the TCP buffers fill. + w.ws_send("stuck", r#"["SILENT"]"#).await; + let big = "x".repeat(256 << 10); + for _ in 0..100 { + w.ws_send("stuck", &big).await; + } + let closed = w.frame().await; + assert_eq!( + (closed["type"].as_str(), closed["reason"].as_str()), + (Some("ws_closed"), Some("relay_silent")), + "{closed}" + ); +} + +#[tokio::test] +async fn a_worker_that_stops_reading_loses_the_link() { + let slow = Limits { + out_frames: 16, + out_bytes: 64 << 10, + ..limits() + }; + let (relay, _gw, mut w) = setup(slow).await; + w.open("f").await; + w.ws_send("f", r#"["SEND",4000,4096]"#).await; + // Stop reading while the relay floods; the queue fills behind the full TCP buffers. + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!(w.closed().await, Some(4003)); + wait_for("the relay socket to close", || relay.open_sockets() == 0).await; +} + +#[tokio::test] +async fn shutdown_closes_sockets_then_the_link() { + let (relay, gw, mut w) = setup(limits()).await; + w.open("s").await; + gw.shutdown.cancel(); + let closed = w.frame().await; + assert_eq!(closed["by"], "gateway"); + assert_eq!(closed["reason"], "shutdown"); + assert_eq!(w.closed().await, Some(1012)); + wait_for("close 1001", || relay.socket_closes().contains(&Some(1001))).await; +} diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 66c80f5d6c6..68c416a9766 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -183,23 +183,6 @@ fn same_occupancy( && index_to_epoch.get(&peer_idx) == Some(&epoch) } -fn mix_remote_stt_samples(mix: &mut Vec, samples: &[f32]) { - if mix.len() < samples.len() { - mix.resize(samples.len(), 0.0); - } - for (mixed, sample) in mix.iter_mut().zip(samples) { - *mixed = (*mixed + *sample).clamp(-1.0, 1.0); - } -} - -fn f32_samples_to_le_bytes(samples: &[f32]) -> Vec { - let mut bytes = Vec::with_capacity(std::mem::size_of_val(samples)); - for sample in samples { - bytes.extend_from_slice(&sample.to_le_bytes()); - } - bytes -} - /// One remote peer's slot: jitter buffer + dedicated rodio Player. /// /// Per-frame seq/timestamp come from the v2 wire header (sender-authored). @@ -280,7 +263,6 @@ pub(crate) async fn run_playout_recv_loop( tts_active: Arc, tts_cancel: Arc, local_tts_publishers: super::tts::LocalTtsPublishers, - remote_stt_pipeline: Arc>>>, agent_pubkeys: Arc>>, human_floor: HumanFloor, ) { @@ -338,7 +320,6 @@ pub(crate) async fn run_playout_recv_loop( // per idle peer into rodio forever. `is_active` is a 500 ms // grace past the last received packet, far longer than typical // DTX comfort-noise cadence. - let mut remote_stt_mix = Vec::new(); for (peer_idx, slot) in peers.iter_mut() { if !slot.is_active() { // Still drain the frame to keep NetEq's internal clock @@ -360,17 +341,10 @@ pub(crate) async fn run_playout_recv_loop( ); slot.player.skip_one(); } - if !is_locally_synthesized_peer(*peer_idx, &local_tts_publishers) { - let remote_agent = { - let agents = agent_pubkeys - .lock() - .unwrap_or_else(|error| error.into_inner()); - is_agent_peer(*peer_idx, &index_to_pubkey, &agents) - }; - if !remote_agent { - mix_remote_stt_samples(&mut remote_stt_mix, &samples); - } - } + // Remote peers are played out, never transcribed: + // this device signs transcripts with the local + // user's key, so another participant's speech has + // no honest path into that pipeline. slot.player.append(SamplesBuffer::new(channels, rate, samples)); } Err(e) => { @@ -380,18 +354,6 @@ pub(crate) async fn run_playout_recv_loop( } } } - if !remote_stt_mix.is_empty() { - let pipeline = remote_stt_pipeline - .lock() - .unwrap_or_else(|error| error.into_inner()) - .as_ref() - .and_then(std::sync::Weak::upgrade); - if let Some(pipeline) = pipeline { - let _ = pipeline.push_remote_audio(f32_samples_to_le_bytes( - &remote_stt_mix, - )); - } - } } _ = speaker_tick.tick() => { release_expired_remote_floors( @@ -776,8 +738,10 @@ mod tests { assert!(!is_locally_synthesized_peer(9, &local_publishers)); } + /// Agent audio plays out, but must never acquire the human floor — + /// otherwise one agent's speech would suppress another agent's response. #[test] - fn remote_agent_identity_is_excluded_from_human_stt() { + fn remote_agent_identity_is_excluded_from_the_human_floor() { let peers = std::collections::HashMap::from([(3, "human".to_owned()), (4, "AGENT".to_owned())]); let agents = vec!["agent".to_owned()]; @@ -812,16 +776,4 @@ mod tests { "frame for an unoccupied index is dropped" ); } - - #[test] - fn remote_human_stt_mix_sums_and_clamps_concurrent_speakers() { - let mut mix = Vec::new(); - mix_remote_stt_samples(&mut mix, &[0.4, -0.7, 0.2]); - mix_remote_stt_samples(&mut mix, &[0.8, -0.6, -0.1]); - - assert_eq!(mix, vec![1.0, -1.0, 0.1]); - let bytes = f32_samples_to_le_bytes(&mix); - assert_eq!(bytes.len(), std::mem::size_of_val(mix.as_slice())); - assert_eq!(f32::from_le_bytes(bytes[0..4].try_into().unwrap()), 1.0); - } } diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 32f15ddf0e7..112f4e229e4 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -110,20 +110,12 @@ pub(crate) async fn connect_audio_relay( let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); // TTS interrupt flags — recv task cancels TTS when remote humans speak. - let ( - tts_cancel, - tts_active, - local_tts_publishers, - remote_stt_pipeline, - agent_pubkeys, - human_floor, - ) = { + let (tts_cancel, tts_active, local_tts_publishers, agent_pubkeys, human_floor) = { let hs = state.huddle()?; ( Arc::clone(&hs.tts_cancel), Arc::clone(&hs.tts_active), Arc::clone(&hs.local_tts_publishers), - Arc::clone(&hs.remote_stt_pipeline), Arc::clone(&hs.agent_pubkeys), hs.human_floor.clone(), ) @@ -163,7 +155,6 @@ pub(crate) async fn connect_audio_relay( tts_cancel, tts_active, local_tts_publishers, - remote_stt_pipeline, agent_pubkeys, human_floor, output_device_name, @@ -357,7 +348,6 @@ struct AudioRelayPipelineArgs { tts_cancel: Arc, tts_active: Arc, local_tts_publishers: super::tts::LocalTtsPublishers, - remote_stt_pipeline: Arc>>>, agent_pubkeys: Arc>>, human_floor: super::human_floor::HumanFloor, output_device_name: Option, @@ -374,7 +364,6 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String tts_cancel, tts_active, local_tts_publishers, - remote_stt_pipeline, agent_pubkeys, human_floor, output_device_name, @@ -441,7 +430,6 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String tts_active, tts_cancel, local_tts_publishers, - remote_stt_pipeline, agent_pubkeys, human_floor, )); diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index c7aff1bf7e2..ac0668553b9 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex, Weak, + Arc, Mutex, }; use super::agent_voice::AgentVoiceSettings; @@ -79,12 +79,6 @@ pub struct HuddleState { /// Active STT pipeline — not serialized, not cloned. #[serde(skip)] pub stt_pipeline: Option>, - /// Weak STT handle shared with the audio receive loop so remote human - /// speech can reach transcription even when the pipeline hot-starts after - /// the Huddle audio socket was connected. The state-owned strong handle - /// above remains the sole owner and teardown clears both atomically. - #[serde(skip)] - pub remote_stt_pipeline: Arc>>>, /// Active TTS pipeline — not serialized, not cloned. #[serde(skip)] pub tts_pipeline: Option>, @@ -198,7 +192,6 @@ impl Clone for HuddleState { agent_pubkeys: Arc::new(Mutex::new(agent_pubkeys_snapshot)), agent_voice_settings: self.agent_voice_settings.clone(), stt_pipeline: None, // Never clone the pipeline handle. - remote_stt_pipeline: Arc::new(Mutex::new(None)), tts_pipeline: None, // Never clone the pipeline handle. local_tts_publishers: Arc::clone(&self.local_tts_publishers), is_creator: self.is_creator, @@ -235,7 +228,6 @@ impl Default for HuddleState { agent_pubkeys: Arc::new(Mutex::new(Vec::new())), agent_voice_settings: BTreeMap::new(), stt_pipeline: None, - remote_stt_pipeline: Arc::new(Mutex::new(None)), tts_pipeline: None, local_tts_publishers: tts::LocalTtsPublishers::default(), is_creator: false, @@ -258,19 +250,15 @@ impl Default for HuddleState { } impl HuddleState { + /// Install the STT pipeline. The state holds the only handle: the audio + /// receive loop must not reach it, because this pipeline's transcripts are + /// signed with the local user's key and it therefore accepts this device's + /// microphone alone. pub(crate) fn set_stt_pipeline(&mut self, pipeline: Arc) { - *self - .remote_stt_pipeline - .lock() - .unwrap_or_else(|error| error.into_inner()) = Some(Arc::downgrade(&pipeline)); self.stt_pipeline = Some(pipeline); } pub(crate) fn take_stt_pipeline(&mut self) -> Option> { - self.remote_stt_pipeline - .lock() - .unwrap_or_else(|error| error.into_inner()) - .take(); self.stt_pipeline.take() } diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 897844e1030..d8f984a5cca 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -17,6 +17,12 @@ //! //! The worker runs on a dedicated `std::thread` (not async) because //! sherpa-onnx is CPU-bound and not Send-safe across await points. +//! +//! **Attribution invariant.** Every transcript this pipeline emits is signed +//! with the local user's key, so only this device's own microphone may enter +//! it. Audio from any other participant — decoded remote peers included — must +//! never reach `push_audio`: transcribing it here would publish one person's +//! speech as another's, on every listening desktop independently. use std::{ collections::VecDeque, @@ -53,26 +59,15 @@ const MAX_SPEECH_SAMPLES: usize = 16_000 * 30; /// task without holding a Mutex across await points. #[derive(Debug)] pub struct SttPipeline { - /// Send raw PCM bytes (f32 LE, 48 kHz mono) into the pipeline. - audio_tx: SyncSender, + /// Send raw PCM bytes (f32 LE, 48 kHz mono) from the local microphone + /// into the pipeline. See the attribution invariant in the module docs. + audio_tx: SyncSender>, /// Signals the worker thread to stop. shutdown: Arc, /// Worker thread handle — taken on drop to join cleanly. thread: Option>, } -#[derive(Debug)] -struct SttAudioInput { - pcm_bytes: Vec, - origin: SttAudioOrigin, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SttAudioOrigin { - Local, - RemoteHuman, -} - impl SttPipeline { /// Spawn the pipeline thread. /// @@ -102,7 +97,7 @@ impl SttPipeline { human_floor: HumanFloor, output_device: Option, ) -> Result<(Self, tokio_mpsc::Receiver), String> { - let (audio_tx, audio_rx) = mpsc::sync_channel::(AUDIO_QUEUE_DEPTH); + let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); let shutdown = Arc::new(AtomicBool::new(false)); @@ -144,23 +139,15 @@ impl SttPipeline { self.thread.as_ref().is_none_or(|h| h.is_finished()) } - /// Feed raw PCM bytes into the pipeline. + /// Feed raw PCM bytes from **this device's microphone** into the pipeline. + /// + /// The only audio entry point, deliberately. Its output is signed with the + /// local user's key, so admitting another participant's audio here would + /// attribute their speech to this user — see the module docs. /// /// Non-blocking. Drops audio silently if the pipeline can't keep up — /// better to lose frames than to stall the UI thread. pub fn push_audio(&self, pcm_bytes: Vec) -> Result<(), String> { - self.push_audio_from(pcm_bytes, SttAudioOrigin::Local) - } - - /// Feed decoded remote-human PCM into transcription. Unlike the desktop - /// microphone path, this is not gated by the desktop PTT or mute state: the - /// remote participant already made their transmission choice on their own - /// device before the relay delivered these samples. - pub fn push_remote_audio(&self, pcm_bytes: Vec) -> Result<(), String> { - self.push_audio_from(pcm_bytes, SttAudioOrigin::RemoteHuman) - } - - fn push_audio_from(&self, pcm_bytes: Vec, origin: SttAudioOrigin) -> Result<(), String> { // Reject non-4-byte-aligned input — would silently truncate in bytes_to_f32. if !pcm_bytes.len().is_multiple_of(4) { return Err(format!( @@ -169,7 +156,7 @@ impl SttPipeline { )); } // Drop audio if the pipeline can't keep up — better than blocking the UI. - let _ = self.audio_tx.try_send(SttAudioInput { pcm_bytes, origin }); + let _ = self.audio_tx.try_send(pcm_bytes); Ok(()) } } @@ -405,11 +392,11 @@ impl SttStreamState { #[derive(Debug)] enum SttLoopInput { Tick, - Batch(Vec), + Batch(Vec>), } fn run_stt_receive_loop( - audio_rx: Receiver, + audio_rx: Receiver>, shutdown: &AtomicBool, human_floor: HumanFloor, mut process: impl FnMut(SttLoopInput, &mut local_barge_in::LocalBargeIn), @@ -443,7 +430,7 @@ fn run_stt_receive_loop( #[allow(clippy::too_many_arguments)] fn stt_worker( model_dir: PathBuf, - audio_rx: Receiver, + audio_rx: Receiver>, text_tx: tokio_mpsc::Sender, shutdown: Arc, ptt_active: Option>, @@ -488,9 +475,7 @@ fn stt_worker( } }; - // ── 2. Independent local and remote processing state ───────────────────── - // Separate resampler/VAD state prevents simultaneous desktop and remote - // speech from being serialized into one artificial utterance. + // ── 2. Local microphone processing state ───────────────────────────────── let mut local_stream = match SttStreamState::new() { Ok(stream) => stream, Err(error) => { @@ -498,13 +483,6 @@ fn stt_worker( return; } }; - let mut remote_stream = match SttStreamState::new() { - Ok(stream) => stream, - Err(error) => { - eprintln!("buzz-desktop: {error}"); - return; - } - }; let speculative_enabled = stt_speculative_decode(); let mut transmit_was_active = ptt_active .as_ref() @@ -546,28 +524,18 @@ fn stt_worker( } } SttLoopInput::Batch(batch) => { - for input in batch { - let (stream, ptt_gate, manual_gate, track_local_floor) = match input.origin { - SttAudioOrigin::Local => ( - &mut local_stream, - ptt_active.as_ref(), - manual_mic_unmuted.as_ref(), - true, - ), - SttAudioOrigin::RemoteHuman => (&mut remote_stream, None, None, false), - }; + for pcm_bytes in batch { process_stt_input( - stream, - &input.pcm_bytes, + &mut local_stream, + &pcm_bytes, speculative_enabled, &recognizer, &text_tx, - ptt_gate, - manual_gate, + ptt_active.as_ref(), + manual_mic_unmuted.as_ref(), &human_floor, local_barge_in_state, output_device.as_deref(), - track_local_floor, ); } } @@ -591,7 +559,6 @@ fn process_stt_input( human_floor: &HumanFloor, local_barge_in_state: &mut local_barge_in::LocalBargeIn, output_device: Option<&str>, - track_local_floor: bool, ) { stream .input_buf_48k @@ -614,7 +581,6 @@ fn process_stt_input( human_floor, local_barge_in_state, output_device, - track_local_floor, ); } } @@ -671,7 +637,6 @@ fn process_16k_samples( human_floor: &HumanFloor, local_barge_in_state: &mut local_barge_in::LocalBargeIn, output_device: Option<&str>, - track_local_floor: bool, ) { let (speculative_enabled, speculative) = speculative; leftover.extend_from_slice(samples); @@ -692,20 +657,17 @@ fn process_16k_samples( endpoint.process_frame(frame, prob, accepts_audio, flush_allowed, flush_frames); // Open-mic VAD semantics also apply when a PTT-mode user manually // opens the mic. A held shortcut keeps its explicit key-down cancel. - let local_barge_in = track_local_floor - && local_barge_in::enabled(ptt_active.is_some(), manually_open, ptt_held); - if track_local_floor { - if local_barge_in { - local_barge_in_state.observe( - prob, - action == VadFrameAction::ConfirmedOnset, - human_floor, - output_device, - VAD_ONSET_THRESHOLD, - ); - } else { - local_barge_in_state.release(human_floor); - } + let local_barge_in = local_barge_in::enabled(ptt_active.is_some(), manually_open, ptt_held); + if local_barge_in { + local_barge_in_state.observe( + prob, + action == VadFrameAction::ConfirmedOnset, + human_floor, + output_device, + VAD_ONSET_THRESHOLD, + ); + } else { + local_barge_in_state.release(human_floor); } match action { diff --git a/desktop/src-tauri/src/huddle/stt_tests.rs b/desktop/src-tauri/src/huddle/stt_tests.rs index d7425970ced..3fba9adafa6 100644 --- a/desktop/src-tauri/src/huddle/stt_tests.rs +++ b/desktop/src-tauri/src/huddle/stt_tests.rs @@ -1,9 +1,9 @@ use std::sync::{atomic::AtomicBool, mpsc, Arc, Barrier}; use super::{ - has_enough_voiced_audio, run_stt_receive_loop, vad_flush_allowed, HumanFloor, SttAudioInput, - SttAudioOrigin, SttLoopInput, VadEndpoint, VadFrameAction, MIN_VOICED_FRAMES, - SILENCE_FLUSH_FRAMES, VAD_FRAME_SAMPLES, VAD_ONSET_FRAMES, VAD_PRE_ROLL_FRAMES, + has_enough_voiced_audio, run_stt_receive_loop, vad_flush_allowed, HumanFloor, SttLoopInput, + VadEndpoint, VadFrameAction, MIN_VOICED_FRAMES, SILENCE_FLUSH_FRAMES, VAD_FRAME_SAMPLES, + VAD_ONSET_FRAMES, VAD_PRE_ROLL_FRAMES, }; #[derive(Clone, Copy)] @@ -34,12 +34,7 @@ fn assert_worker_exit_releases_floor(exit: WorkerExit) { ); }); - audio_tx - .send(SttAudioInput { - pcm_bytes: Vec::new(), - origin: SttAudioOrigin::Local, - }) - .expect("worker receiver is open"); + audio_tx.send(Vec::new()).expect("worker receiver is open"); acquired.wait(); assert!(human_floor.is_blocked()); match exit {