From b7e4409abdb16ca5a97e951f43839641ece5230d Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 08:26:05 -0500 Subject: [PATCH 01/22] feat(relay-gateway): add the crate with its config and route table Bishop (a Cloudflare Worker) cannot reach Block's hosted relay directly: Cloudflare-to-Cloudflare requests end in error 526, while a Cloudflare Container gets through. buzz-relay-gateway is the keyless forwarder that runs in that container. The Worker keeps every key and signs every event; the gateway only forwards bytes. This first part is the skeleton. - Config::from_env reads four public values (bind address, relay URL, link URL, gateway-auth public key) and never holds a secret. A missing or invalid variable names the variable, never its value. - The relay URL must be wss://host[:port] with nothing else; ws:// is accepted only on loopback, for the offline tests. - policy.rs is the anti-open-proxy table: exact method and path matches (POST /query, /events, /count; GET / for NIP-11; the socket on /), a closed request header set with no duplicates, and a response filter that keeps only content-type and retry-after. - The binary serves /healthz, installs the ring provider, logs JSON, and handles SIGTERM itself (it is PID 1 in the image). Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 19 ++ Cargo.toml | 1 + crates/buzz-relay-gateway/Cargo.toml | 33 ++++ crates/buzz-relay-gateway/src/config.rs | 159 +++++++++++++++ crates/buzz-relay-gateway/src/lib.rs | 38 ++++ crates/buzz-relay-gateway/src/main.rs | 58 ++++++ crates/buzz-relay-gateway/src/policy.rs | 249 ++++++++++++++++++++++++ 7 files changed, 557 insertions(+) create mode 100644 crates/buzz-relay-gateway/Cargo.toml create mode 100644 crates/buzz-relay-gateway/src/config.rs create mode 100644 crates/buzz-relay-gateway/src/lib.rs create mode 100644 crates/buzz-relay-gateway/src/main.rs create mode 100644 crates/buzz-relay-gateway/src/policy.rs diff --git a/Cargo.lock b/Cargo.lock index 9206cb0c0e4..5c06f6a84a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,6 +1505,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-relay-gateway" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "nostr 0.44.7", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "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 df77ab7cb3c..51f1db14a9f 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/crates/buzz-relay-gateway/Cargo.toml b/crates/buzz-relay-gateway/Cargo.toml new file mode 100644 index 00000000000..9366096f7fc --- /dev/null +++ b/crates/buzz-relay-gateway/Cargo.toml @@ -0,0 +1,33 @@ +[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 } +nostr = { 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-util = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-relay-gateway/src/config.rs b/crates/buzz-relay-gateway/src/config.rs new file mode 100644 index 00000000000..750b5265a9b --- /dev/null +++ b/crates/buzz-relay-gateway/src/config.rs @@ -0,0 +1,159 @@ +//! Environment configuration. Every value is public. + +use std::net::SocketAddr; + +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)) +} + +#[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/lib.rs b/crates/buzz-relay-gateway/src/lib.rs new file mode 100644 index 00000000000..5998126e196 --- /dev/null +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -0,0 +1,38 @@ +//! 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 config; +pub mod policy; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; + +pub use config::Config; + +/// Serves `/healthz` on `listener` until `shutdown` is cancelled. +pub async fn serve(listener: TcpListener, shutdown: CancellationToken) -> std::io::Result<()> { + let app = Router::new() + .route("/healthz", get(healthz)) + .with_state(shutdown.clone()); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await +} + +async fn healthz(State(shutdown): State) -> Response { + if shutdown.is_cancelled() { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } else { + "ok".into_response() + } +} diff --git a/crates/buzz-relay-gateway/src/main.rs b/crates/buzz-relay-gateway/src/main.rs new file mode 100644 index 00000000000..77bf85c43b9 --- /dev/null +++ b/crates/buzz-relay-gateway/src/main.rs @@ -0,0 +1,58 @@ +use buzz_relay_gateway::Config; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::EnvFilter; + +#[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(); + tracing_subscriber::fmt() + .json() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .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, 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()) + ] + ); + } +} From c9848c6b2b0cef4d454af17177c7acd333a5df2e Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 09:08:48 -0500 Subject: [PATCH 02/22] fix(relay-gateway): cap the shutdown wait and keep tungstenite out of logs Review fixes for part 1. - axum::serve sets no hyper timer, so a client that stops mid-request holds hyper's graceful shutdown open for good, and SIGTERM never leads to an exit. serve() now returns 2 s after its work is done whatever the open connections are doing. tests/serve.rs leaves a request half sent and checks that serve returns. - fmt().init() also bridges `log` records, and at trace tungstenite logs every frame it sends or receives in full. log_filter() builds the filter from RUST_LOG and always turns tungstenite and tokio_tungstenite off, so no RUST_LOG value prints link or relay frames. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/src/lib.rs | 32 +++++++++++++++++++++--- crates/buzz-relay-gateway/src/main.rs | 6 ++--- crates/buzz-relay-gateway/tests/serve.rs | 26 +++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 crates/buzz-relay-gateway/tests/serve.rs diff --git a/crates/buzz-relay-gateway/src/lib.rs b/crates/buzz-relay-gateway/src/lib.rs index 5998126e196..40737dcdb11 100644 --- a/crates/buzz-relay-gateway/src/lib.rs +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -7,6 +7,8 @@ pub mod config; pub mod policy; +use std::{future::IntoFuture, time::Duration}; + use axum::{ extract::State, http::StatusCode, @@ -16,17 +18,41 @@ use axum::{ }; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; +use tracing_subscriber::EnvFilter; pub use config::Config; +// 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); + /// Serves `/healthz` on `listener` until `shutdown` is cancelled. pub async fn serve(listener: TcpListener, shutdown: CancellationToken) -> std::io::Result<()> { let app = Router::new() .route("/healthz", get(healthz)) .with_state(shutdown.clone()); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown.cancelled_owned()) - .await + let server = + axum::serve(listener, app).with_graceful_shutdown(shutdown.clone().cancelled_owned()); + tokio::select! { + served = server.into_future() => served, + () = async { + shutdown.cancelled().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(shutdown): State) -> Response { diff --git a/crates/buzz-relay-gateway/src/main.rs b/crates/buzz-relay-gateway/src/main.rs index 77bf85c43b9..d6dbe99b8cf 100644 --- a/crates/buzz-relay-gateway/src/main.rs +++ b/crates/buzz-relay-gateway/src/main.rs @@ -1,17 +1,15 @@ use buzz_relay_gateway::Config; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; -use tracing_subscriber::EnvFilter; #[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( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) + .with_env_filter(buzz_relay_gateway::log_filter(&directives)) .init(); let config = match Config::from_env() { Ok(config) => config, diff --git a/crates/buzz-relay-gateway/tests/serve.rs b/crates/buzz-relay-gateway/tests/serve.rs new file mode 100644 index 00000000000..e813b1ea13f --- /dev/null +++ b/crates/buzz-relay-gateway/tests/serve.rs @@ -0,0 +1,26 @@ +use std::time::Duration; + +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, + time::timeout, +}; +use tokio_util::sync::CancellationToken; + +#[tokio::test] +async fn a_half_sent_request_does_not_hold_up_shutdown() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut tcp = TcpStream::connect(listener.local_addr().unwrap()) + .await + .unwrap(); + let shutdown = CancellationToken::new(); + let server = tokio::spawn(buzz_relay_gateway::serve(listener, shutdown.clone())); + // 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; + shutdown.cancel(); + let served = timeout(Duration::from_secs(5), server).await; + assert!(matches!(served, Ok(Ok(Ok(())))), "serve did not return"); +} From 9111346fe910c463c2742607ef2007fb1bf324be Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 08:27:25 -0500 Subject: [PATCH 03/22] feat(relay-gateway): gate /v1/link with a single-use NIP-98 bearer The Worker opens a link with Authorization: Nostr , a kind 27235 event signed by a dedicated gateway-auth key that is never a relay member. buzz_auth::verify_nip98_event checks the kind, the signature, the 60 s window, one u (the configured link URL) and one method (GET); the gateway then requires the pinned pubkey and exactly one nonce of 16 to 128 characters. - Each bearer works once: its event id is claimed in an in-memory replay cache for the relay's 120 s TTL. Only a bearer that passed every other check uses a slot, and a full cache refuses new bearers with 503 rather than forgetting old ones. - Refusals answer 401 {"error":"unauthorized"} and log only a reason code (missing, malformed, invalid, wrong_key, no_nonce, replayed), never the header or the verifier's text, which quotes bearer content. - At most four links at once; the frame loop lands in a later part, so a good bearer gets 501 for now. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/buzz-relay-gateway/Cargo.toml | 5 + crates/buzz-relay-gateway/src/auth.rs | 251 ++++++++++++++++++ crates/buzz-relay-gateway/src/config.rs | 23 +- crates/buzz-relay-gateway/src/lib.rs | 74 +++++- crates/buzz-relay-gateway/src/main.rs | 4 +- crates/buzz-relay-gateway/tests/common/mod.rs | 75 ++++++ crates/buzz-relay-gateway/tests/link.rs | 46 ++++ crates/buzz-relay-gateway/tests/serve.rs | 23 +- 9 files changed, 477 insertions(+), 27 deletions(-) create mode 100644 crates/buzz-relay-gateway/src/auth.rs create mode 100644 crates/buzz-relay-gateway/tests/common/mod.rs create mode 100644 crates/buzz-relay-gateway/tests/link.rs diff --git a/Cargo.lock b/Cargo.lock index 5c06f6a84a2..aafb5d50b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1511,7 +1511,10 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", + "buzz-auth", "nostr 0.44.7", + "reqwest 0.13.4", "rustls", "serde", "serde_json", diff --git a/crates/buzz-relay-gateway/Cargo.toml b/crates/buzz-relay-gateway/Cargo.toml index 9366096f7fc..e2a342853c2 100644 --- a/crates/buzz-relay-gateway/Cargo.toml +++ b/crates/buzz-relay-gateway/Cargo.toml @@ -18,6 +18,8 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } axum = { workspace = true } +base64 = { workspace = true } +buzz-auth = { workspace = true } nostr = { 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. @@ -31,3 +33,6 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } uuid = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } diff --git a/crates/buzz-relay-gateway/src/auth.rs b/crates/buzz-relay-gateway/src/auth.rs new file mode 100644 index 00000000000..ccfa288fc52 --- /dev/null +++ b/crates/buzz-relay-gateway/src/auth.rs @@ -0,0 +1,251 @@ +//! 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, +/// which the caller then claims in the [`ReplayCache`]. +pub fn verify_link_bearer( + headers: &HeaderMap, + link_url: &str, + pubkey: &PublicKey, + max_len: usize, +) -> Result { + 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), + _ => Err(Reject::NoNonce), + } +} + +/// Event ids of accepted bearers, each kept for the relay's replay TTL +/// (twice the ±60 s window). 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 `id` once. Refuses a replay, and refuses every id while full. + pub fn claim(&self, id: EventId, now: Instant) -> Result<(), Reject> { + let ttl = Duration::from_secs(DEFAULT_REPLAY_TTL_SECS); + let mut seen = self.seen.lock().unwrap_or_else(PoisonError::into_inner); + seen.retain(|_, at| now.saturating_duration_since(*at) < ttl); + if seen.contains_key(&id) { + return Err(Reject::Replayed); + } + if seen.len() >= self.cap { + return Err(Reject::CacheFull); + } + seen.insert(id, now); + 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 { + 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 = Instant::now(); + assert_eq!(cache.claim(a, now), Ok(())); + assert_eq!(cache.claim(a, now), Err(Reject::Replayed)); + assert_eq!(cache.claim(b, now), Err(Reject::CacheFull)); + let later = now + Duration::from_secs(DEFAULT_REPLAY_TTL_SECS); + assert_eq!(cache.claim(b, later), Ok(())); + } +} diff --git a/crates/buzz-relay-gateway/src/config.rs b/crates/buzz-relay-gateway/src/config.rs index 750b5265a9b..2e0178b032c 100644 --- a/crates/buzz-relay-gateway/src/config.rs +++ b/crates/buzz-relay-gateway/src/config.rs @@ -1,4 +1,4 @@ -//! Environment configuration. Every value is public. +//! Environment configuration (all public values) and the fixed limits. use std::net::SocketAddr; @@ -90,6 +90,27 @@ fn relay_urls(raw: &str) -> Option<(Url, Url)> { bare.then_some((ws, http)) } +/// Fixed limits (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, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_links: 4, + max_auth_header: 8 << 10, + replay_entries: 1024, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay-gateway/src/lib.rs b/crates/buzz-relay-gateway/src/lib.rs index 40737dcdb11..ef93a05cde1 100644 --- a/crates/buzz-relay-gateway/src/lib.rs +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -4,33 +4,59 @@ //! link bearer and forwards bytes. It holds no secret. #![deny(unsafe_code)] +pub mod auth; pub mod config; pub mod policy; -use std::{future::IntoFuture, time::Duration}; +use std::{ + future::IntoFuture, + sync::Arc, + time::{Duration, Instant}, +}; use axum::{ extract::State, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::get, - Router, + Json, Router, }; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, sync::Semaphore}; use tokio_util::sync::CancellationToken; use tracing_subscriber::EnvFilter; -pub use config::Config; +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); -/// Serves `/healthz` on `listener` until `shutdown` is cancelled. -pub async fn serve(listener: TcpListener, shutdown: CancellationToken) -> std::io::Result<()> { +struct AppState { + config: Config, + limits: Limits, + replay: auth::ReplayCache, + links: Arc, + shutdown: CancellationToken, +} + +/// Serves `/healthz` and `/v1/link` on `listener` until `shutdown` is cancelled. +pub async fn serve( + listener: TcpListener, + config: Config, + limits: Limits, + shutdown: CancellationToken, +) -> std::io::Result<()> { + let state = Arc::new(AppState { + replay: auth::ReplayCache::new(limits.replay_entries), + links: Arc::new(Semaphore::new(limits.max_links)), + shutdown: shutdown.clone(), + config, + limits, + }); let app = Router::new() .route("/healthz", get(healthz)) - .with_state(shutdown.clone()); + .route("/v1/link", get(link)) + .with_state(state); let server = axum::serve(listener, app).with_graceful_shutdown(shutdown.clone().cancelled_owned()); tokio::select! { @@ -55,10 +81,38 @@ pub fn log_filter(directives: &str) -> EnvFilter { .fold(filter, EnvFilter::add_directive) } -async fn healthz(State(shutdown): State) -> Response { - if shutdown.is_cancelled() { +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) -> 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(|id| s.replay.claim(id, Instant::now())); + match claimed { + 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() + } + // The link's frame loop lands in a later change. + Ok(()) => StatusCode::NOT_IMPLEMENTED.into_response(), + } +} diff --git a/crates/buzz-relay-gateway/src/main.rs b/crates/buzz-relay-gateway/src/main.rs index d6dbe99b8cf..792d4206d03 100644 --- a/crates/buzz-relay-gateway/src/main.rs +++ b/crates/buzz-relay-gateway/src/main.rs @@ -1,4 +1,4 @@ -use buzz_relay_gateway::Config; +use buzz_relay_gateway::{Config, Limits}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; @@ -35,7 +35,7 @@ async fn main() -> anyhow::Result<()> { shutdown.cancel(); } }); - buzz_relay_gateway::serve(listener, shutdown).await?; + buzz_relay_gateway::serve(listener, config, Limits::default(), shutdown).await?; Ok(()) } 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..8a3dd608a30 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/common/mod.rs @@ -0,0 +1,75 @@ +//! Shared harness: the gateway in-process and a bearer signer. +#![allow(dead_code)] + +use std::net::SocketAddr; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_relay_gateway::{Config, Limits}; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use tokio::{net::TcpListener, task::JoinHandle}; +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, "") +} + +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) + } +} diff --git a/crates/buzz-relay-gateway/tests/link.rs b/crates/buzz-relay-gateway/tests/link.rs new file mode 100644 index 00000000000..bf1bab88483 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/link.rs @@ -0,0 +1,46 @@ +mod common; + +use buzz_relay_gateway::Limits; +use common::{bearer, Gateway}; +use nostr::Keys; + +#[tokio::test] +async fn bearer_gate_is_single_use_and_fails_closed() { + let gw = Gateway::start( + "ws://127.0.0.1:9", + Limits { + replay_entries: 1, + ..Limits::default() + }, + ) + .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. + let good = bearer(&gw.keys); + assert_eq!(link(Some(&good)).await.status(), 501); + 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(); +} diff --git a/crates/buzz-relay-gateway/tests/serve.rs b/crates/buzz-relay-gateway/tests/serve.rs index e813b1ea13f..f682f365bc0 100644 --- a/crates/buzz-relay-gateway/tests/serve.rs +++ b/crates/buzz-relay-gateway/tests/serve.rs @@ -1,26 +1,21 @@ +mod common; + use std::time::Duration; -use tokio::{ - io::AsyncWriteExt, - net::{TcpListener, TcpStream}, - time::timeout, -}; -use tokio_util::sync::CancellationToken; +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 listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let mut tcp = TcpStream::connect(listener.local_addr().unwrap()) - .await - .unwrap(); - let shutdown = CancellationToken::new(); - let server = tokio::spawn(buzz_relay_gateway::serve(listener, shutdown.clone())); + 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; - shutdown.cancel(); - let served = timeout(Duration::from_secs(5), server).await; + gw.shutdown.cancel(); + let served = timeout(Duration::from_secs(5), gw.task).await; assert!(matches!(served, Ok(Ok(Ok(())))), "serve did not return"); } From f63f9865717b0c95296ae2f296098006f72e03d5 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 09:12:26 -0500 Subject: [PATCH 04/22] fix(relay-gateway): keep claimed bearer ids 2 s past the relay's TTL Review fix for part 2. The bearer window is inclusive and counted in whole seconds, so a bearer dated 60 s ahead is valid for up to 121 s, while the cache let its id go after exactly 120 s. Ids are now kept for 122 s, and the unit test checks that an id is still refused at 120 s. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/src/auth.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/buzz-relay-gateway/src/auth.rs b/crates/buzz-relay-gateway/src/auth.rs index ccfa288fc52..bb83049ebe1 100644 --- a/crates/buzz-relay-gateway/src/auth.rs +++ b/crates/buzz-relay-gateway/src/auth.rs @@ -80,8 +80,12 @@ pub fn verify_link_bearer( } } -/// Event ids of accepted bearers, each kept for the relay's replay TTL -/// (twice the ±60 s window). A restart empties it. +/// 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); + +/// Event ids of accepted bearers, each kept for `REPLAY_TTL`. A restart empties it. pub struct ReplayCache { cap: usize, seen: Mutex>, @@ -98,9 +102,8 @@ impl ReplayCache { /// Claims `id` once. Refuses a replay, and refuses every id while full. pub fn claim(&self, id: EventId, now: Instant) -> Result<(), Reject> { - let ttl = Duration::from_secs(DEFAULT_REPLAY_TTL_SECS); let mut seen = self.seen.lock().unwrap_or_else(PoisonError::into_inner); - seen.retain(|_, at| now.saturating_duration_since(*at) < ttl); + seen.retain(|_, at| now.saturating_duration_since(*at) < REPLAY_TTL); if seen.contains_key(&id) { return Err(Reject::Replayed); } @@ -245,7 +248,9 @@ mod tests { assert_eq!(cache.claim(a, now), Ok(())); assert_eq!(cache.claim(a, now), Err(Reject::Replayed)); assert_eq!(cache.claim(b, now), Err(Reject::CacheFull)); - let later = now + Duration::from_secs(DEFAULT_REPLAY_TTL_SECS); - assert_eq!(cache.claim(b, later), Ok(())); + // 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), Err(Reject::Replayed)); + assert_eq!(cache.claim(b, now + REPLAY_TTL), Ok(())); } } From 5abb8ce7d1d8c585dee903875c3d16fd9a21023f Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 09:34:59 -0500 Subject: [PATCH 05/22] fix(relay-gateway): keep a bearer id while the window could still accept it The 122 s monotonic hold covers the inclusive whole-second window, but a backward wall-clock step after the claim could make the verifier accept a bearer the cache had already forgotten. Each entry now also stays until the wall clock is past its created_at plus the window. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/src/auth.rs | 67 ++++++++++++++++++++------- crates/buzz-relay-gateway/src/lib.rs | 6 ++- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/buzz-relay-gateway/src/auth.rs b/crates/buzz-relay-gateway/src/auth.rs index bb83049ebe1..ae8206ca3bb 100644 --- a/crates/buzz-relay-gateway/src/auth.rs +++ b/crates/buzz-relay-gateway/src/auth.rs @@ -46,14 +46,14 @@ impl Reject { } } -/// Checks the link bearer on an upgrade request and returns its event id, -/// which the caller then claims in the [`ReplayCache`]. +/// 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 { +) -> 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); @@ -75,7 +75,9 @@ pub fn verify_link_bearer( .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), + (Some([_, n, ..]), None) if (16..=128).contains(&n.chars().count()) => { + Ok((event.id, event.created_at.as_secs())) + } _ => Err(Reject::NoNonce), } } @@ -85,10 +87,15 @@ pub fn verify_link_bearer( /// 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); -/// Event ids of accepted bearers, each kept for `REPLAY_TTL`. A restart empties it. +/// `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>, + seen: Mutex>, } impl ReplayCache { @@ -100,17 +107,26 @@ impl ReplayCache { } } - /// Claims `id` once. Refuses a replay, and refuses every id while full. - pub fn claim(&self, id: EventId, now: Instant) -> Result<(), Reject> { + /// 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| now.saturating_duration_since(*at) < REPLAY_TTL); + 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); + seen.insert(id, (now, created_at)); Ok(()) } } @@ -151,7 +167,7 @@ mod tests { )) } - fn check(keys: &Keys, values: &[&str]) -> Result { + 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()); @@ -244,13 +260,30 @@ mod tests { check(&k, &[&good(&k)]).unwrap(), ); let cache = ReplayCache::new(1); - let now = Instant::now(); - assert_eq!(cache.claim(a, now), Ok(())); - assert_eq!(cache.claim(a, now), Err(Reject::Replayed)); - assert_eq!(cache.claim(b, now), Err(Reject::CacheFull)); + 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), Err(Reject::Replayed)); - assert_eq!(cache.claim(b, now + REPLAY_TTL), Ok(())); + 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/lib.rs b/crates/buzz-relay-gateway/src/lib.rs index ef93a05cde1..09bcf5fb695 100644 --- a/crates/buzz-relay-gateway/src/lib.rs +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -21,6 +21,7 @@ use axum::{ routing::get, Json, Router, }; +use nostr::Timestamp; use tokio::{net::TcpListener, sync::Semaphore}; use tokio_util::sync::CancellationToken; use tracing_subscriber::EnvFilter; @@ -102,7 +103,10 @@ async fn link(State(s): State>, headers: HeaderMap) -> Response { &s.config.link_pubkey, s.limits.max_auth_header, ) - .and_then(|id| s.replay.claim(id, Instant::now())); + .and_then(|bearer| { + s.replay + .claim(bearer, Instant::now(), Timestamp::now().as_secs()) + }); match claimed { Err(reject) => { tracing::warn!(reason = reject.code(), "link bearer refused"); From 948cdde8bad401b19f4ebd671f0456aa1667cf8b Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 08:27:55 -0500 Subject: [PATCH 06/22] feat(relay-gateway): define the v1 link frames Every frame is one JSON object in a WebSocket text frame, with a type field. Worker to gateway: http, ws_open, ws_send, ws_close, ping, pong. Gateway to Worker: hello, http_res, ws_opened, ws_msg, ws_closed, error, rotate, ping, pong. Bodies travel as standard base64 so they arrive byte for byte, and relay socket text travels as a JSON string, sent as is. - Inbound frames parse through one flat struct, because serde_json's RawValue does not survive internally tagged enums. That keeps the raw headers object, so the policy still sees a duplicated key. - An unknown type maps to Unknown (the link stays open), even when it reuses a known field name with another type. A known type with a missing or mistyped field is BadFrame. - Request and socket ids are 1 to 64 of [A-Za-z0-9_-], so they are safe to log. - The frame types that carry headers, bodies or relay text do not derive Debug, so they cannot reach a log line by accident. - Every error code has a fixed message that never quotes frame content. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/src/frame.rs | 440 +++++++++++++++++++++++++ crates/buzz-relay-gateway/src/lib.rs | 1 + 2 files changed, 441 insertions(+) create mode 100644 crates/buzz-relay-gateway/src/frame.rs diff --git a/crates/buzz-relay-gateway/src/frame.rs b/crates/buzz-relay-gateway/src/frame.rs new file mode 100644 index 00000000000..bdb074f5f75 --- /dev/null +++ b/crates/buzz-relay-gateway/src/frame.rs @@ -0,0 +1,440 @@ +//! 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. +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. +pub enum Inbound { + /// Forward one HTTP request. + 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. + Unknown, +} + +/// Not JSON, no `type`, or a known type with a missing or mistyped field. +#[derive(Debug, PartialEq, Eq)] +pub struct BadFrame; + +// Flat on purpose: `RawValue` does not survive serde's internally tagged enums. +#[derive(Deserialize)] +struct Raw { + #[serde(rename = "type")] + kind: String, + id: Option, + sid: Option, + method: Option, + path: Option, + headers: Option>, + body_b64: Option, + text: Option, + code: Option, + reason: Option, + ts: Option, +} + +#[derive(Deserialize)] +struct TypeOnly { + #[serde(rename = "type")] + kind: String, +} + +const KNOWN: [&str; 6] = ["http", "ws_open", "ws_send", "ws_close", "ping", "pong"]; + +impl Inbound { + /// Parses one text frame. + pub fn parse(text: &str) -> Result { + let raw: Raw = match serde_json::from_str(text) { + Ok(raw) => raw, + // A newer frame type may use a known field name with another type. + Err(_) => { + return match serde_json::from_str::(text) { + Ok(t) if !KNOWN.contains(&t.kind.as_str()) => Ok(Self::Unknown), + _ => Err(BadFrame), + } + } + }; + let sid = || raw.sid.clone().ok_or(BadFrame); + Ok(match raw.kind.as_str() { + "http" => Self::Http(HttpFrame { + id: raw.id.ok_or(BadFrame)?, + method: raw.method.ok_or(BadFrame)?, + path: raw.path.ok_or(BadFrame)?, + headers: raw.headers, + body_b64: raw.body_b64, + }), + "ws_open" => Self::WsOpen { + sid: sid()?, + path: raw.path.ok_or(BadFrame)?, + }, + "ws_send" => Self::WsSend { + sid: sid()?, + text: raw.text.ok_or(BadFrame)?, + }, + "ws_close" => Self::WsClose { + sid: sid()?, + code: raw.code, + reason: raw.reason, + }, + "ping" => Self::Ping { ts: raw.ts }, + "pong" => Self::Pong, + _ => Self::Unknown, + }) + } +} + +/// 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) + )); + } + + #[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"}"#, + ] { + 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/lib.rs b/crates/buzz-relay-gateway/src/lib.rs index 09bcf5fb695..b3f3787359c 100644 --- a/crates/buzz-relay-gateway/src/lib.rs +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod config; +pub mod frame; pub mod policy; use std::{ From 5fc3a0bfaf3b5b75b8d9f7444418796d182ef8b2 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 09:13:51 -0500 Subject: [PATCH 07/22] fix(relay-gateway): parse each frame type with only its own fields Review fix for part 3. Inbound::parse read every frame into one flat struct, so each field name had one type for all frame types. A ping carrying "code":"later" (a ws_close field) failed to parse, got bad_frame, and closed the link with 1008, although the protocol says unknown fields are ignored and new optional fields stay v1. parse() now reads `type` first, then deserializes the text as that type alone: HttpFrame for http (RawValue needs a plain struct), and a serde-tagged Inbound for the rest, with #[serde(other)] for unknown types. A duplicated top-level key is still bad_frame. The unit tests gain foreign-field cases for ping, pong, ws_send and http. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/src/frame.rs | 83 +++++++++----------------- 1 file changed, 27 insertions(+), 56 deletions(-) diff --git a/crates/buzz-relay-gateway/src/frame.rs b/crates/buzz-relay-gateway/src/frame.rs index bdb074f5f75..0486c19ef05 100644 --- a/crates/buzz-relay-gateway/src/frame.rs +++ b/crates/buzz-relay-gateway/src/frame.rs @@ -34,6 +34,7 @@ impl Id { } /// 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, @@ -48,8 +49,12 @@ pub struct HttpFrame { } /// A frame from the Worker. +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum Inbound { - /// Forward one HTTP request. + /// 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 }, @@ -66,6 +71,7 @@ pub enum Inbound { /// The answer to a gateway `ping`. Pong, /// A `type` this version does not know; the link stays open. + #[serde(other)] Unknown, } @@ -73,70 +79,23 @@ pub enum Inbound { #[derive(Debug, PartialEq, Eq)] pub struct BadFrame; -// Flat on purpose: `RawValue` does not survive serde's internally tagged enums. -#[derive(Deserialize)] -struct Raw { - #[serde(rename = "type")] - kind: String, - id: Option, - sid: Option, - method: Option, - path: Option, - headers: Option>, - body_b64: Option, - text: Option, - code: Option, - reason: Option, - ts: Option, -} - #[derive(Deserialize)] struct TypeOnly { #[serde(rename = "type")] kind: String, } -const KNOWN: [&str; 6] = ["http", "ws_open", "ws_send", "ws_close", "ping", "pong"]; - impl Inbound { - /// Parses one text frame. + /// 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 raw: Raw = match serde_json::from_str(text) { - Ok(raw) => raw, - // A newer frame type may use a known field name with another type. - Err(_) => { - return match serde_json::from_str::(text) { - Ok(t) if !KNOWN.contains(&t.kind.as_str()) => Ok(Self::Unknown), - _ => Err(BadFrame), - } - } + 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) }; - let sid = || raw.sid.clone().ok_or(BadFrame); - Ok(match raw.kind.as_str() { - "http" => Self::Http(HttpFrame { - id: raw.id.ok_or(BadFrame)?, - method: raw.method.ok_or(BadFrame)?, - path: raw.path.ok_or(BadFrame)?, - headers: raw.headers, - body_b64: raw.body_b64, - }), - "ws_open" => Self::WsOpen { - sid: sid()?, - path: raw.path.ok_or(BadFrame)?, - }, - "ws_send" => Self::WsSend { - sid: sid()?, - text: raw.text.ok_or(BadFrame)?, - }, - "ws_close" => Self::WsClose { - sid: sid()?, - code: raw.code, - reason: raw.reason, - }, - "ping" => Self::Ping { ts: raw.ts }, - "pong" => Self::Pong, - _ => Self::Unknown, - }) + frame.map_err(|_| BadFrame) } } @@ -327,6 +286,16 @@ mod tests { 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] @@ -340,6 +309,8 @@ mod tests { 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}"); } From 9a2eabbd157409b32736e0c2fb09e567ee6a491e Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 08:29:30 -0500 Subject: [PATCH 08/22] feat(relay-gateway): run each link as a task with bounded output A good bearer now upgrades to the link WebSocket. The link sends hello (version 1, link_id, expiry and the limits), answers ping with pong, and pings the Worker every 20 s. HTTP and relay socket frames answer not_supported until the next parts land. - Lifetime: rotate 5 minutes before the hour, then close 4001, so the Worker's reconnect path runs every hour and one bearer never lasts long. - Idle: no Worker frame for 60 s closes 4002. - Backpressure: frames for the Worker go into a queue bounded at 1,024 frames and 16 MiB of JSON, with try_send only. Nothing waits on it; when it fills, the Worker is not reading and the link closes 4003. - Protocol: a binary frame closes 1003 (reserved for the Phase 2 audio bridge), a malformed frame gets bad_frame then 1008, a frame over 2 MiB closes 1009, and an unknown type gets unknown_type with the link open. - Shutdown: /v1/link and /healthz answer 503, links close 1012, and serve keeps accepting until every tracked link has closed. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + crates/buzz-relay-gateway/Cargo.toml | 2 + crates/buzz-relay-gateway/src/config.rs | 43 +++- crates/buzz-relay-gateway/src/lib.rs | 48 ++-- crates/buzz-relay-gateway/src/link.rs | 214 ++++++++++++++++++ crates/buzz-relay-gateway/tests/common/mod.rs | 102 ++++++++- crates/buzz-relay-gateway/tests/link.rs | 149 +++++++++++- 7 files changed, 537 insertions(+), 23 deletions(-) create mode 100644 crates/buzz-relay-gateway/src/link.rs diff --git a/Cargo.lock b/Cargo.lock index aafb5d50b5d..e2e598ae4d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1513,6 +1513,7 @@ dependencies = [ "axum", "base64 0.22.1", "buzz-auth", + "futures-util", "nostr 0.44.7", "reqwest 0.13.4", "rustls", @@ -1520,6 +1521,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.29.0", "tokio-util", "tracing", "tracing-subscriber", diff --git a/crates/buzz-relay-gateway/Cargo.toml b/crates/buzz-relay-gateway/Cargo.toml index e2a342853c2..1ae3392df7b 100644 --- a/crates/buzz-relay-gateway/Cargo.toml +++ b/crates/buzz-relay-gateway/Cargo.toml @@ -20,6 +20,7 @@ anyhow = { workspace = true } axum = { workspace = true } base64 = { workspace = true } buzz-auth = { workspace = true } +futures-util = { workspace = true } nostr = { 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. @@ -36,3 +37,4 @@ uuid = { workspace = true } [dev-dependencies] reqwest = { workspace = true } +tokio-tungstenite = { workspace = true } diff --git a/crates/buzz-relay-gateway/src/config.rs b/crates/buzz-relay-gateway/src/config.rs index 2e0178b032c..ed8c8c408e0 100644 --- a/crates/buzz-relay-gateway/src/config.rs +++ b/crates/buzz-relay-gateway/src/config.rs @@ -1,6 +1,6 @@ //! Environment configuration (all public values) and the fixed limits. -use std::net::SocketAddr; +use std::{net::SocketAddr, time::Duration}; use nostr::PublicKey; use url::Url; @@ -90,7 +90,7 @@ fn relay_urls(raw: &str) -> Option<(Url, Url)> { bare.then_some((ws, http)) } -/// Fixed limits (design §5). Not configurable by env; tests pass smaller values. +/// 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. @@ -99,6 +99,32 @@ pub struct Limits { 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, + /// 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, + /// 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, + /// How long shutdown waits for in-flight HTTP requests. + pub shutdown_grace: Duration, } impl Default for Limits { @@ -107,6 +133,19 @@ impl Default for Limits { 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_http_inflight: 16, + max_sockets: 8, + out_frames: 1024, + out_bytes: 16 << 20, + link_lifetime: Duration::from_secs(3600), + rotate_before: Duration::from_secs(300), + ping_every: Duration::from_secs(20), + idle: Duration::from_secs(60), + shutdown_grace: Duration::from_secs(10), } } } diff --git a/crates/buzz-relay-gateway/src/lib.rs b/crates/buzz-relay-gateway/src/lib.rs index b3f3787359c..a26d5bfd3f1 100644 --- a/crates/buzz-relay-gateway/src/lib.rs +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -7,6 +7,7 @@ pub mod auth; pub mod config; pub mod frame; +mod link; pub mod policy; use std::{ @@ -16,7 +17,7 @@ use std::{ }; use axum::{ - extract::State, + extract::{ws::rejection::WebSocketUpgradeRejection, State, WebSocketUpgrade}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::get, @@ -24,7 +25,7 @@ use axum::{ }; use nostr::Timestamp; use tokio::{net::TcpListener, sync::Semaphore}; -use tokio_util::sync::CancellationToken; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; use tracing_subscriber::EnvFilter; pub use config::{Config, Limits}; @@ -39,9 +40,11 @@ struct AppState { replay: auth::ReplayCache, links: Arc, shutdown: CancellationToken, + tracker: TaskTracker, } -/// Serves `/healthz` and `/v1/link` on `listener` until `shutdown` is cancelled. +/// 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, @@ -52,19 +55,27 @@ pub async fn serve( 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); - let server = - axum::serve(listener, app).with_graceful_shutdown(shutdown.clone().cancelled_owned()); + .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 { - shutdown.cancelled().await; + tracker.wait().await; tokio::time::sleep(LAST_REQUESTS).await; } => Ok(()), } @@ -91,11 +102,15 @@ async fn healthz(State(s): State>) -> Response { } } -async fn link(State(s): State>, headers: HeaderMap) -> 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 { + let Ok(permit) = s.links.clone().try_acquire_owned() else { return StatusCode::SERVICE_UNAVAILABLE.into_response(); }; let claimed = auth::verify_link_bearer( @@ -108,8 +123,8 @@ async fn link(State(s): State>, headers: HeaderMap) -> Response { s.replay .claim(bearer, Instant::now(), Timestamp::now().as_secs()) }); - match claimed { - Err(reject) => { + match (claimed, upgrade) { + (Err(reject), _) => { tracing::warn!(reason = reject.code(), "link bearer refused"); if reject == auth::Reject::CacheFull { return StatusCode::SERVICE_UNAVAILABLE.into_response(); @@ -117,7 +132,14 @@ async fn link(State(s): State>, headers: HeaderMap) -> Response { let body = Json(serde_json::json!({ "error": "unauthorized" })); (StatusCode::UNAUTHORIZED, body).into_response() } - // The link's frame loop lands in a later change. - Ok(()) => StatusCode::NOT_IMPLEMENTED.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..a1d58ea860d --- /dev/null +++ b/crates/buzz-relay-gateway/src/link.rs @@ -0,0 +1,214 @@ +//! One task per link: `hello`, frame dispatch, pings, rotation, idle and +//! backpressure, and the link's close. + +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use axum::extract::ws::{CloseFrame, Message, WebSocket}; +use futures_util::{stream::SplitSink, SinkExt, StreamExt}; +use tokio::{ + sync::{mpsc, mpsc::error::TrySendError, OwnedSemaphorePermit, Semaphore}, + time::{interval_at, sleep_until, timeout, Instant}, +}; +use tokio_util::sync::CancellationToken; + +use crate::{ + frame::{ErrorCode, HelloLimits, Id, Inbound, Out}, + AppState, +}; + +// Teardown waits this long 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 Link { + state: Arc, + id: String, + out: Outbox, +} + +/// 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, + }; + 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" + ); + 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; + 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() => return SHUTDOWN, + } + } + } + + /// 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 { + // HTTP forwarding and relay sockets land in later changes. + Inbound::Http(f) => self.refuse(ErrorCode::NotSupported, Some(f.id), None), + Inbound::WsOpen { sid, .. } => self.refuse(ErrorCode::NotSupported, None, Some(sid)), + Inbound::WsSend { sid, .. } | Inbound::WsClose { sid, .. } => { + self.refuse(ErrorCode::UnknownSocket, 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)); + } +} diff --git a/crates/buzz-relay-gateway/tests/common/mod.rs b/crates/buzz-relay-gateway/tests/common/mod.rs index 8a3dd608a30..9b497fcf88e 100644 --- a/crates/buzz-relay-gateway/tests/common/mod.rs +++ b/crates/buzz-relay-gateway/tests/common/mod.rs @@ -1,12 +1,19 @@ -//! Shared harness: the gateway in-process and a bearer signer. +//! Shared harness: the gateway in-process, a mock Worker and a bearer signer. #![allow(dead_code)] -use std::net::SocketAddr; +use std::{net::SocketAddr, time::Duration}; 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 tokio::{net::TcpListener, task::JoinHandle}; +use serde_json::Value; +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"; @@ -34,6 +41,14 @@ 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 { + shutdown_grace: Duration::from_millis(500), + ..Limits::default() + } +} + pub struct Gateway { pub addr: SocketAddr, pub keys: Keys, @@ -72,4 +87,85 @@ impl Gateway { 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; + } + } + } } diff --git a/crates/buzz-relay-gateway/tests/link.rs b/crates/buzz-relay-gateway/tests/link.rs index bf1bab88483..4b5a699bc12 100644 --- a/crates/buzz-relay-gateway/tests/link.rs +++ b/crates/buzz-relay-gateway/tests/link.rs @@ -1,16 +1,24 @@ mod common; +use std::time::Duration; + use buzz_relay_gateway::Limits; -use common::{bearer, Gateway}; +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( - "ws://127.0.0.1:9", + NO_RELAY, Limits { replay_entries: 1, - ..Limits::default() + ..limits() }, ) .await; @@ -27,9 +35,10 @@ async fn bearer_gate_is_single_use_and_fails_closed() { 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. + // 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(), 501); + 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); @@ -44,3 +53,133 @@ async fn bearer_gate_is_single_use_and_fails_closed() { 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(); +} From 723226e8c086f9923e82dabe09fbd7407359c9a1 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 08:32:02 -0500 Subject: [PATCH 09/22] feat(relay-gateway): forward http frames to the relay byte for byte An http frame now becomes exactly one request to the pinned relay origin, on a path taken from the route table, never from frame text. The decoded body_b64 bytes go out as the body and the response bytes come back base64-encoded as they arrived, so a NIP-98 payload tag the Worker signed still verifies at the relay. - The client never follows redirects (a 3xx comes back as a status, its location dropped), ignores proxy env, never decompresses, and is https-only for a wss relay. It sets only user-agent; nothing can add X-Forwarded-*, X-Pubkey, cookies, Origin or Accept-Encoding, or move Host. - No retries, ever: a retry would burn the NIP-98 event id. A 429 and its body pass through untouched. - Limits: 16 requests in flight per link (busy beyond), a 1 MiB request body, a 4 MiB response read in chunks, 10 s to connect and 30 s per request (upstream_connect, upstream_timeout). - Shutdown gives in-flight requests up to 10 s while new ones get shutting_down, then closes the link 1012. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + crates/buzz-relay-gateway/Cargo.toml | 4 +- crates/buzz-relay-gateway/src/config.rs | 6 + crates/buzz-relay-gateway/src/http_forward.rs | 71 ++++++ crates/buzz-relay-gateway/src/lib.rs | 3 + crates/buzz-relay-gateway/src/link.rs | 104 +++++++- crates/buzz-relay-gateway/tests/common/mod.rs | 182 +++++++++++++- crates/buzz-relay-gateway/tests/http.rs | 225 ++++++++++++++++++ 8 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 crates/buzz-relay-gateway/src/http_forward.rs create mode 100644 crates/buzz-relay-gateway/tests/http.rs diff --git a/Cargo.lock b/Cargo.lock index e2e598ae4d3..648b5a84012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1514,11 +1514,13 @@ dependencies = [ "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", diff --git a/crates/buzz-relay-gateway/Cargo.toml b/crates/buzz-relay-gateway/Cargo.toml index 1ae3392df7b..1503f35d8e6 100644 --- a/crates/buzz-relay-gateway/Cargo.toml +++ b/crates/buzz-relay-gateway/Cargo.toml @@ -22,6 +22,7 @@ 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"] } @@ -36,5 +37,6 @@ url = { workspace = true } uuid = { workspace = true } [dev-dependencies] -reqwest = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } tokio-tungstenite = { workspace = true } diff --git a/crates/buzz-relay-gateway/src/config.rs b/crates/buzz-relay-gateway/src/config.rs index ed8c8c408e0..d56ecbecd14 100644 --- a/crates/buzz-relay-gateway/src/config.rs +++ b/crates/buzz-relay-gateway/src/config.rs @@ -123,6 +123,10 @@ pub struct Limits { pub ping_every: Duration, /// A link with no Worker frame for this long closes with 4002. pub idle: Duration, + /// Reaching the relay: TCP and TLS. + pub connect_timeout: Duration, + /// One whole HTTP exchange with the relay. + pub request_timeout: Duration, /// How long shutdown waits for in-flight HTTP requests. pub shutdown_grace: Duration, } @@ -145,6 +149,8 @@ impl Default for Limits { 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), shutdown_grace: Duration::from_secs(10), } } 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..db708964dd1 --- /dev/null +++ b/crates/buzz-relay-gateway/src/http_forward.rs @@ -0,0 +1,71 @@ +//! 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 { + if e.is_timeout() { + ErrorCode::UpstreamTimeout + } else { + ErrorCode::UpstreamConnect + } +} diff --git a/crates/buzz-relay-gateway/src/lib.rs b/crates/buzz-relay-gateway/src/lib.rs index a26d5bfd3f1..0188703ff66 100644 --- a/crates/buzz-relay-gateway/src/lib.rs +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -7,6 +7,7 @@ pub mod auth; pub mod config; pub mod frame; +mod http_forward; mod link; pub mod policy; @@ -41,6 +42,7 @@ struct AppState { links: Arc, shutdown: CancellationToken, tracker: TaskTracker, + client: reqwest::Client, } /// Serves `/healthz` and `/v1/link` on `listener`. Once `shutdown` is @@ -52,6 +54,7 @@ pub async fn serve( 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(), diff --git a/crates/buzz-relay-gateway/src/link.rs b/crates/buzz-relay-gateway/src/link.rs index a1d58ea860d..4e2f85f04ac 100644 --- a/crates/buzz-relay-gateway/src/link.rs +++ b/crates/buzz-relay-gateway/src/link.rs @@ -2,21 +2,24 @@ //! backpressure, and the link's close. 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, Id, Inbound, Out}, - AppState, + frame::{ErrorCode, HelloLimits, HttpFrame, Id, Inbound, Out}, + http_forward, policy, AppState, }; // Teardown waits this long for the Worker to take the close frame. @@ -70,6 +73,9 @@ struct Link { state: Arc, id: String, out: Outbox, + draining: bool, + http: JoinSet<()>, + inflight: HashMap, } /// Runs one link until it closes. `_permit` holds its slot under the link cap. @@ -105,6 +111,9 @@ pub(crate) async fn run(socket: WebSocket, state: Arc, _permit: OwnedS state: state.clone(), id, out, + draining: false, + http: JoinSet::new(), + inflight: HashMap::new(), }; let (code, reason) = link .serve(&mut stream, opened + lim.link_lifetime, opened + rotate_in) @@ -116,6 +125,7 @@ pub(crate) async fn run(socket: WebSocket, state: Arc, _permit: OwnedS ms = ms(opened), "link closing" ); + link.http.abort_all(); let close = Message::Close(Some(CloseFrame { code, reason: reason.into(), @@ -146,6 +156,7 @@ impl Link { 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() => { @@ -176,7 +187,20 @@ impl Link { _ = sleep_until(expires) => return (4001, "rotate"), _ = sleep_until(idle) => return (4002, "idle"), _ = self.out.overflow.cancelled() => return (4003, "backpressure"), - _ = self.state.shutdown.cancelled() => return SHUTDOWN, + _ = 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; + } + } } } } @@ -188,8 +212,13 @@ impl Link { return Err((1008, "bad frame")); }; match frame { - // HTTP forwarding and relay sockets land in later changes. - Inbound::Http(f) => self.refuse(ErrorCode::NotSupported, Some(f.id), None), + Inbound::Http(f) => { + let id = f.id.clone(); + if let Err(code) = self.http(f) { + self.refuse(code, Some(id), None); + } + } + // Relay sockets land in the next change. Inbound::WsOpen { sid, .. } => self.refuse(ErrorCode::NotSupported, None, Some(sid)), Inbound::WsSend { sid, .. } | Inbound::WsClose { sid, .. } => { self.refuse(ErrorCode::UnknownSocket, None, Some(sid)) @@ -211,4 +240,69 @@ impl Link { ); 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(()) + } } diff --git a/crates/buzz-relay-gateway/tests/common/mod.rs b/crates/buzz-relay-gateway/tests/common/mod.rs index 9b497fcf88e..d84b7b44b8b 100644 --- a/crates/buzz-relay-gateway/tests/common/mod.rs +++ b/crates/buzz-relay-gateway/tests/common/mod.rs @@ -1,13 +1,27 @@ -//! Shared harness: the gateway in-process, a mock Worker and a bearer signer. +//! Shared harness: the gateway in-process, a mock relay (HTTP side), a mock Worker +//! and a bearer signer. #![allow(dead_code)] -use std::{net::SocketAddr, time::Duration}; +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{Arc, Mutex}, + time::Duration, +}; +use axum::{ + body::{Body, Bytes}, + extract::State, + 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::Value; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use tokio::{net::TcpListener, task::JoinHandle, time::timeout}; use tokio_tungstenite::{ connect_async, @@ -44,6 +58,7 @@ pub fn bearer(keys: &Keys) -> String { /// 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() } @@ -168,4 +183,165 @@ impl Worker { } } } + + 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 + } +} + +/// 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. +#[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 url: Mutex, +} + +/// The mock relay: HTTP routes on every path. +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) + } +} + +async fn handle( + State(s): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + 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(); + 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() + } + } } diff --git a/crates/buzz-relay-gateway/tests/http.rs b/crates/buzz-relay-gateway/tests/http.rs new file mode 100644 index 00000000000..8bf16699d10 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/http.rs @@ -0,0 +1,225 @@ +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, 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 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(); +} From 3b7159d59295f193cca3333ec322252a8016ec54 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 09:15:37 -0500 Subject: [PATCH 10/22] fix(relay-gateway): report an expired connect timeout as upstream_connect Review fix for part 5. reqwest marks an expired connect_timeout as a timeout too, so a relay that never answered the TCP or TLS handshake was reported as upstream_timeout. That code means a request may have reached the relay; upstream_connect means it did not. A connect error is now upstream_connect whatever its cause. The test for it lands in part 7, which has room for it. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/src/http_forward.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay-gateway/src/http_forward.rs b/crates/buzz-relay-gateway/src/http_forward.rs index db708964dd1..72e8e037940 100644 --- a/crates/buzz-relay-gateway/src/http_forward.rs +++ b/crates/buzz-relay-gateway/src/http_forward.rs @@ -63,7 +63,8 @@ pub(crate) async fn forward( } fn upstream(e: reqwest::Error) -> ErrorCode { - if e.is_timeout() { + // 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 From d31598b9c3e684e789609ca24e3d1d44f73ca6be Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 08:34:30 -0500 Subject: [PATCH 11/22] feat(relay-gateway): hold NIP-42 relay sockets for the Worker ws_open now opens one relay WebSocket on the pinned wss origin plus /, with only a user-agent header; no Worker header is forwarded, because NIP-42 runs in-band. The relay's ["AUTH", challenge] reaches the Worker as an ordinary ws_msg, and the Worker's signed reply goes back with ws_send. The gateway parses no Nostr frames: it only times the AUTH exchange by prefix (auth_challenge_ms, auth_reply_ms), since the relay closes a socket 5 s after its challenge. - Every socket reports exactly one ws_closed, with by (relay, gateway or worker), the close code, and http_status when the upgrade was refused. - The gateway keeps reading every relay socket, so the relay's pings are answered at once. A relay frame over 1 MiB, 90 s of relay silence, or 64 Worker frames the relay is not taking close that one socket. - ws_send text over 512 KiB gets frame_too_large and the socket stays open, so one bad frame cannot drop every subscription on it. - At most 8 sockets per link. The huddle audio path is reserved for the Phase 2 bridge and answers not_supported. - No relay socket outlives its link: on any link end each socket closes (1001 on shutdown, after in-flight HTTP drains), reports ws_closed, and anything still running after 2 s is aborted. The gateway never reopens a socket or replays a REQ; reconnects are the Worker's job. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/Cargo.toml | 2 +- crates/buzz-relay-gateway/src/config.rs | 11 +- crates/buzz-relay-gateway/src/lib.rs | 1 + crates/buzz-relay-gateway/src/link.rs | 111 +++++++++- crates/buzz-relay-gateway/src/relay_ws.rs | 197 ++++++++++++++++++ crates/buzz-relay-gateway/tests/common/mod.rs | 109 +++++++++- crates/buzz-relay-gateway/tests/ws.rs | 165 +++++++++++++++ 7 files changed, 582 insertions(+), 14 deletions(-) create mode 100644 crates/buzz-relay-gateway/src/relay_ws.rs create mode 100644 crates/buzz-relay-gateway/tests/ws.rs diff --git a/crates/buzz-relay-gateway/Cargo.toml b/crates/buzz-relay-gateway/Cargo.toml index 1503f35d8e6..33243ae84cc 100644 --- a/crates/buzz-relay-gateway/Cargo.toml +++ b/crates/buzz-relay-gateway/Cargo.toml @@ -30,6 +30,7 @@ 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 } @@ -39,4 +40,3 @@ uuid = { workspace = true } [dev-dependencies] hex = { workspace = true } sha2 = { workspace = true } -tokio-tungstenite = { workspace = true } diff --git a/crates/buzz-relay-gateway/src/config.rs b/crates/buzz-relay-gateway/src/config.rs index d56ecbecd14..57f4df543e7 100644 --- a/crates/buzz-relay-gateway/src/config.rs +++ b/crates/buzz-relay-gateway/src/config.rs @@ -107,6 +107,8 @@ pub struct Limits { 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. @@ -115,6 +117,8 @@ pub struct Limits { 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`. @@ -123,10 +127,12 @@ pub struct Limits { pub ping_every: Duration, /// A link with no Worker frame for this long closes with 4002. pub idle: Duration, - /// Reaching the relay: TCP and TLS. + /// 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, } @@ -141,16 +147,19 @@ impl Default for Limits { 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), } } diff --git a/crates/buzz-relay-gateway/src/lib.rs b/crates/buzz-relay-gateway/src/lib.rs index 0188703ff66..1818516ca0f 100644 --- a/crates/buzz-relay-gateway/src/lib.rs +++ b/crates/buzz-relay-gateway/src/lib.rs @@ -10,6 +10,7 @@ pub mod frame; mod http_forward; mod link; pub mod policy; +mod relay_ws; use std::{ future::IntoFuture, diff --git a/crates/buzz-relay-gateway/src/link.rs b/crates/buzz-relay-gateway/src/link.rs index 4e2f85f04ac..c0036cfe7c3 100644 --- a/crates/buzz-relay-gateway/src/link.rs +++ b/crates/buzz-relay-gateway/src/link.rs @@ -1,5 +1,5 @@ //! One task per link: `hello`, frame dispatch, pings, rotation, idle and -//! backpressure, and the link's close. +//! backpressure, and the teardown of everything the link opened. use std::{ collections::HashMap, @@ -19,10 +19,12 @@ use tokio_util::sync::CancellationToken; use crate::{ frame::{ErrorCode, HelloLimits, HttpFrame, Id, Inbound, Out}, - http_forward, policy, AppState, + http_forward, policy, + relay_ws::{Cmd, Socket}, + AppState, }; -// Teardown waits this long for the Worker to take the close frame. +// 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"); @@ -69,6 +71,11 @@ impl Outbox { } } +struct SocketHandle { + tx: mpsc::Sender, + overflow: CancellationToken, +} + struct Link { state: Arc, id: String, @@ -76,6 +83,9 @@ struct Link { 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. @@ -114,6 +124,9 @@ pub(crate) async fn run(socket: WebSocket, state: Arc, _permit: OwnedS 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) @@ -125,7 +138,14 @@ pub(crate) async fn run(socket: WebSocket, state: Arc, _permit: OwnedS 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(), @@ -201,6 +221,9 @@ impl Link { return SHUTDOWN; } } + Some(_) = self.socket_tasks.join_next(), if !self.socket_tasks.is_empty() => { + self.sockets.retain(|_, socket| !socket.tx.is_closed()); + } } } } @@ -218,10 +241,34 @@ impl Link { self.refuse(code, Some(id), None); } } - // Relay sockets land in the next change. - Inbound::WsOpen { sid, .. } => self.refuse(ErrorCode::NotSupported, None, Some(sid)), - Inbound::WsSend { sid, .. } | Inbound::WsClose { sid, .. } => { - self.refuse(ErrorCode::UnknownSocket, None, Some(sid)) + 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 => {} @@ -305,4 +352,54 @@ impl Link { 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/relay_ws.rs b/crates/buzz-relay-gateway/src/relay_ws.rs new file mode 100644 index 00000000000..6be7e13c08b --- /dev/null +++ b/crates/buzz-relay-gateway/src/relay_ws.rs @@ -0,0 +1,197 @@ +//! 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())); + 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; + } + if ws.send(Message::Text(text.into())).await.is_err() { + return Closed { by: Side::Relay, code: None, reason: "connection_lost".into(), http_status: None }; + } + continue; + } + 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 index d84b7b44b8b..4397aa0d491 100644 --- a/crates/buzz-relay-gateway/tests/common/mod.rs +++ b/crates/buzz-relay-gateway/tests/common/mod.rs @@ -1,5 +1,4 @@ -//! Shared harness: the gateway in-process, a mock relay (HTTP side), a mock Worker -//! and a bearer signer. +//! Shared harness: the gateway in-process, a mock relay, a mock Worker and a bearer signer. #![allow(dead_code)] use std::{ @@ -11,7 +10,10 @@ use std::{ use axum::{ body::{Body, Bytes}, - extract::State, + extract::{ + ws::{rejection::WebSocketUpgradeRejection, Message as AxMessage, WebSocket}, + State, WebSocketUpgrade, + }, http::{HeaderMap, Method, StatusCode, Uri}, response::{IntoResponse, Response}, Router, @@ -211,6 +213,39 @@ impl Worker { ) .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. @@ -231,7 +266,7 @@ pub fn sha256_hex(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) } -/// How the mock relay answers HTTP. +/// 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. @@ -251,10 +286,12 @@ pub enum Mode { 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. +/// The mock relay: HTTP routes on every path and NIP-42 sockets on `/`. pub struct Relay { pub addr: SocketAddr, pub state: Arc, @@ -288,6 +325,14 @@ impl Relay { .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( @@ -295,11 +340,15 @@ async fn handle( 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() }) @@ -345,3 +394,53 @@ async fn handle( } } } + +/// 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/ws.rs b/crates/buzz-relay-gateway/tests/ws.rs new file mode 100644 index 00000000000..ea542a0e076 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/ws.rs @@ -0,0 +1,165 @@ +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"}) + ); + 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_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; +} From 59f315ea5b121b9567d62abb55edc70a8cd899e6 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 08:36:29 -0500 Subject: [PATCH 12/22] feat(relay-gateway): package the gateway as a container image Dockerfile.relay-gateway builds a static musl binary on Alpine and runs it on distroless static as the nonroot user, on port 8080, which is what a Cloudflare Container needs (linux/amd64). Both base images are pinned by digest. The image env holds only the bind address and RUST_LOG (plus the base image's PATH and SSL_CERT_FILE); the three relay and link values come from the Durable Object's start() env, and none of them is secret. The binary is PID 1, so it handles SIGTERM itself. The image is about 6 MB compressed. tests/logs.rs runs one HTTP forward and one socket AUTH whose bearer, body and frames all carry a marker, at TRACE level, and checks that the captured log holds neither the marker nor "Nostr ". It is its own test binary: tracing caches callsite interest process-wide, so a scoped subscriber misses events while other tests run on other threads. The README states the trust boundary, the environment and how to test. The tests are not in the Justfile unit-test list yet, so CI does not run them. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- Dockerfile.relay-gateway | 28 ++++++++++ crates/buzz-relay-gateway/README.md | 62 ++++++++++++++++++++++ crates/buzz-relay-gateway/tests/logs.rs | 70 +++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 Dockerfile.relay-gateway create mode 100644 crates/buzz-relay-gateway/README.md create mode 100644 crates/buzz-relay-gateway/tests/logs.rs 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/crates/buzz-relay-gateway/README.md b/crates/buzz-relay-gateway/README.md new file mode 100644 index 00000000000..d44e351de14 --- /dev/null +++ b/crates/buzz-relay-gateway/README.md @@ -0,0 +1,62 @@ +# 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` | + +`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. They are not yet in the `Justfile` unit-test +list, so CI does not run them; run them locally on every change. + +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/tests/logs.rs b/crates/buzz-relay-gateway/tests/logs.rs new file mode 100644 index 00000000000..56533745aa0 --- /dev/null +++ b/crates/buzz-relay-gateway/tests/logs.rs @@ -0,0 +1,70 @@ +//! Its own test binary: tracing caches callsite interest process-wide, so a +//! scoped subscriber misses events while other tests run on other threads. +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(()) + } +} + +// current_thread (the tokio::test default) keeps every task under this thread's subscriber. +#[tokio::test] +async fn logs_never_carry_bearers_bodies_or_frames() { + const MARK: &str = "MARK7f3a"; + let capture = Capture::default(); + let writer = capture.clone(); + let subscriber = tracing_subscriber::fmt() + .json() + .with_max_level(tracing::Level::TRACE) + .with_writer(move || writer.clone()) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + 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"); + assert!(!logs.contains(MARK), "marker leaked into logs"); + assert!(!logs.contains("Nostr "), "a bearer leaked into logs"); +} From d56cc9ea7b7179eb5f2cd544ca31fb17e003e314 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 09:21:09 -0500 Subject: [PATCH 13/22] fix(relay-gateway): keep a stalled relay write under the socket's limits Review fixes for parts 5 to 7. They sit here because parts 5 and 6 are at the PR size limit. - A Worker frame's write to the relay ran inside the socket loop's select! arm, so a relay that stopped reading stalled the whole task: the queue overflow, the silence timer and the link's end went unseen, and later ws_send frames were dropped with no ws_closed. The write now races those three, so the socket still closes with backpressure, relay_silent or link_closed. - When the relay closed a socket, the task returned at once and dropped tungstenite's queued close reply. It now flushes it (1 s cap), so the relay gets its close echoed, as RFC 6455 5.5.1 asks. - tests/http.rs checks that a relay that never finishes the TLS handshake gets upstream_connect (the part 5 fix). - tests/logs.rs installs the global subscriber through log_filter("trace") as main.rs does, `log` bridge included, and checks that a dependency's `log` record arrives. Without the tungstenite pins it now fails. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-relay-gateway/README.md | 2 +- crates/buzz-relay-gateway/src/relay_ws.rs | 16 ++++++++++--- crates/buzz-relay-gateway/tests/http.rs | 23 +++++++++++++++++- crates/buzz-relay-gateway/tests/logs.rs | 16 +++++++------ crates/buzz-relay-gateway/tests/ws.rs | 29 +++++++++++++++++++++++ 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/crates/buzz-relay-gateway/README.md b/crates/buzz-relay-gateway/README.md index d44e351de14..69a839fd8ad 100644 --- a/crates/buzz-relay-gateway/README.md +++ b/crates/buzz-relay-gateway/README.md @@ -37,7 +37,7 @@ limits in `src/config.rs`, and the link close codes in `src/link.rs`. | `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` | +| `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, diff --git a/crates/buzz-relay-gateway/src/relay_ws.rs b/crates/buzz-relay-gateway/src/relay_ws.rs index 6be7e13c08b..641a9d091de 100644 --- a/crates/buzz-relay-gateway/src/relay_ws.rs +++ b/crates/buzz-relay-gateway/src/relay_ws.rs @@ -147,6 +147,8 @@ impl Socket { } 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, @@ -162,10 +164,18 @@ impl Socket { tracing::info!(link_id, sid, auth_reply_ms = ms(at), "worker answered AUTH"); challenge = None; } - if ws.send(Message::Text(text.into())).await.is_err() { - return Closed { by: Side::Relay, code: None, reason: "connection_lost".into(), http_status: 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"), } - continue; } Some(Cmd::Close(code, reason)) => { let frame = CloseFrame { code: code.into(), reason: reason.clone().into() }; diff --git a/crates/buzz-relay-gateway/tests/http.rs b/crates/buzz-relay-gateway/tests/http.rs index 8bf16699d10..219a0486e63 100644 --- a/crates/buzz-relay-gateway/tests/http.rs +++ b/crates/buzz-relay-gateway/tests/http.rs @@ -4,7 +4,7 @@ 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, Mode}; +use common::{body_of, limits, setup, sha256_hex, Gateway, Mode}; use nostr::{EventBuilder, Keys, Kind, Tag}; use serde_json::{json, Value}; @@ -203,6 +203,27 @@ async fn in_flight_cap_duplicates_and_timeouts() { 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; diff --git a/crates/buzz-relay-gateway/tests/logs.rs b/crates/buzz-relay-gateway/tests/logs.rs index 56533745aa0..f3558172d63 100644 --- a/crates/buzz-relay-gateway/tests/logs.rs +++ b/crates/buzz-relay-gateway/tests/logs.rs @@ -1,5 +1,6 @@ -//! Its own test binary: tracing caches callsite interest process-wide, so a -//! scoped subscriber misses events while other tests run on other threads. +//! 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::{ @@ -24,18 +25,17 @@ impl Write for Capture { } } -// current_thread (the tokio::test default) keeps every task under this thread's subscriber. #[tokio::test] async fn logs_never_carry_bearers_bodies_or_frames() { const MARK: &str = "MARK7f3a"; let capture = Capture::default(); let writer = capture.clone(); - let subscriber = tracing_subscriber::fmt() + // RUST_LOG=trace: the most anyone can turn on. + tracing_subscriber::fmt() .json() - .with_max_level(tracing::Level::TRACE) + .with_env_filter(buzz_relay_gateway::log_filter("trace")) .with_writer(move || writer.clone()) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); + .init(); let relay = Relay::start().await; let gw = Gateway::start(&relay.ws_url(), limits()).await; @@ -65,6 +65,8 @@ async fn logs_never_carry_bearers_bodies_or_frames() { 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/ws.rs b/crates/buzz-relay-gateway/tests/ws.rs index ea542a0e076..71f246e1c5a 100644 --- a/crates/buzz-relay-gateway/tests/ws.rs +++ b/crates/buzz-relay-gateway/tests/ws.rs @@ -46,6 +46,11 @@ async fn closes_reach_the_other_side() { 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; @@ -136,6 +141,30 @@ async fn relay_side_limits_close_one_socket() { 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 { From 6477e7a6e6a93c8ac72b6701a1bdc123b0335f51 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 27 Oct 2026 09:36:27 -0500 Subject: [PATCH 14/22] ci(relay-gateway): run the gateway tests in just test-unit Workspace membership only buys clippy and check; the unit lane lists each crate it tests. nextest gives each test its own process, which tests/logs.rs needs for its global subscriber. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- Justfile | 3 +++ crates/buzz-relay-gateway/README.md | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index 312ec115b97..21c1fcea7ee 100644 --- a/Justfile +++ b/Justfile @@ -462,6 +462,9 @@ test-unit: # contract/race tests run in the dedicated CI job below. cargo nextest run -p buzz-push-gateway cargo nextest run -p buzz-push-gateway --features personal-dev-app-attest + # Relay gateway (Bishop's keyless relay forwarder): infra-free tests + # against a loopback mock relay and Worker. + cargo nextest run -p buzz-relay-gateway # Kubernetes backend provider: the decision layers (state machine, GC # planner, env precedence, naming, wire) are pure functions with a fake # substrate, so they belong in the unit job. Enumerated explicitly diff --git a/crates/buzz-relay-gateway/README.md b/crates/buzz-relay-gateway/README.md index 69a839fd8ad..f96f6004264 100644 --- a/crates/buzz-relay-gateway/README.md +++ b/crates/buzz-relay-gateway/README.md @@ -51,8 +51,7 @@ 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. They are not yet in the `Justfile` unit-test -list, so CI does not run them; run them locally on every change. +NIP-42 socket) and a mock Worker. CI runs them through `just test-unit`. The image builds from the repo root. Pushing it is a deployment step, not a build step: From 2230b165e4d1373197b7dbddf5d6c55c6d14ecb6 Mon Sep 17 00:00:00 2001 From: hr-o <126079238+hr-o@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:53:21 +0200 Subject: [PATCH 15/22] fix(desktop): transcribe only the local microphone in Huddles Each Huddle transcript is a kind:9 event. The local user signs this event. Thus, do not send audio from other participants to the STT pipeline. If the desktop transcribes remote audio, it incorrectly identifies the remote speaker as the local user. This error occurs on each desktop that receives the audio. Remove the remote-human transcription path. Do not filter remote transcripts before publication. The STT pipeline now receives audio only from the local microphone. This design also prevents Parakeet from processing remote audio that the desktop cannot publish correctly. Known regression: legacy text-only agents no longer receive transcripts of speech from mobile participants. These agents cannot respond to that speech until an agent audio runtime is available. An agent that joins the Opus room directly does not depend on transcripts and is therefore unaffected, but no such agent exists yet. Signed-off-by: hr-o <126079238+hr-o@users.noreply.github.com> (cherry picked from commit afad63572fa53825d038ecc874cac130fb29dd6e) Fork note: taken unchanged from upstream PR 7043 as the huddle-voices slice 0 stopgap. Applies cleanly on fork main 68fdecf44; the applied diff is identical to upstream's, so an upstream sync sees the same change. Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/huddle/playout.rs | 62 ++---------- desktop/src-tauri/src/huddle/relay_api.rs | 14 +-- desktop/src-tauri/src/huddle/state.rs | 22 +---- desktop/src-tauri/src/huddle/stt.rs | 110 +++++++--------------- desktop/src-tauri/src/huddle/stt_tests.rs | 13 +-- 5 files changed, 53 insertions(+), 168 deletions(-) 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 84953329781..02d7d34c39c 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(), ) @@ -156,7 +148,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, @@ -350,7 +341,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, @@ -367,7 +357,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, @@ -434,7 +423,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 { From d23f1a81fc37c9353a54c8269521432c2f3fd6d5 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Fri, 30 Oct 2026 05:31:22 -0500 Subject: [PATCH 16/22] ci(relay-gateway): run the gateway tests from _ci-rust.yml, not the Justfile Editing the Justfile matches CI's desktop path filter, so this PR kept pulling in the desktop e2e suite and its flakes. The Unit Tests job now runs the same `cargo nextest run -p buzz-relay-gateway` as its own step, and the PR no longer touches the Justfile. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- .github/workflows/_ci-rust.yml | 6 ++++++ Justfile | 3 --- crates/buzz-relay-gateway/README.md | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/_ci-rust.yml b/.github/workflows/_ci-rust.yml index e25639d3ac7..a09ca691b71 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 diff --git a/Justfile b/Justfile index 21c1fcea7ee..312ec115b97 100644 --- a/Justfile +++ b/Justfile @@ -462,9 +462,6 @@ test-unit: # contract/race tests run in the dedicated CI job below. cargo nextest run -p buzz-push-gateway cargo nextest run -p buzz-push-gateway --features personal-dev-app-attest - # Relay gateway (Bishop's keyless relay forwarder): infra-free tests - # against a loopback mock relay and Worker. - cargo nextest run -p buzz-relay-gateway # Kubernetes backend provider: the decision layers (state machine, GC # planner, env precedence, naming, wire) are pure functions with a fake # substrate, so they belong in the unit job. Enumerated explicitly diff --git a/crates/buzz-relay-gateway/README.md b/crates/buzz-relay-gateway/README.md index f96f6004264..7e03649a3db 100644 --- a/crates/buzz-relay-gateway/README.md +++ b/crates/buzz-relay-gateway/README.md @@ -51,7 +51,7 @@ 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 through `just test-unit`. +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: From 39c0706d7755d2089ec58b6a2c809e3fe4bd35d6 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Fri, 30 Oct 2026 05:52:23 -0500 Subject: [PATCH 17/22] feat(huddle-bridge): bridge crate, audio join and frames-v1 contract Slice 3a of the huddle live-voices design (part 1 of 3; the input module follows on stacked branches). - New crate crates/buzz-huddle-bridge, built on buzz-audio-client. It joins the workspace as a path dev-dependency of buzz-acp, so the root Cargo.toml is unchanged. A dev-dependency keeps Opus and cmake out of the buzz-acp release, cross (musl) and sprig builds until slice 8a uses the crate and moves it to [dependencies]. - join: the audio upgrade as a Request (headers such as a NIP-FI assertion can be added later), the kind 22242 answer signed by the agent with its NIP-OA owner tag (the tags buzz-acp's relay auth uses), and buzz_audio_client's connect, which sends wire::PROTOCOL_VERSION. A refusal (including upgrade_required) is logged with that version. - frames: every v1 frame between the PC bridge and Talos /df/voice as serde types, frames-v1/schema.json, and one golden fixture per frame (27) for Talos to copy. Tests round-trip every fixture, check the schema names the same frames and fields, and refuse other versions. - Windows CI runs cargo test -p buzz-huddle-bridge. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/_ci-rust.yml | 3 + Cargo.lock | 14 ++ crates/buzz-acp/Cargo.toml | 3 + crates/buzz-huddle-bridge/Cargo.toml | 17 ++ .../frames-v1/fixtures/awake.json | 1 + .../frames-v1/fixtures/close.json | 1 + .../frames-v1/fixtures/elevated.json | 1 + .../frames-v1/fixtures/floor.json | 1 + .../frames-v1/fixtures/handoff.json | 1 + .../frames-v1/fixtures/meter.json | 1 + .../frames-v1/fixtures/mute.json | 1 + .../frames-v1/fixtures/notice.json | 1 + .../frames-v1/fixtures/onset.json | 1 + .../frames-v1/fixtures/open.json | 1 + .../frames-v1/fixtures/opened.json | 1 + .../frames-v1/fixtures/ping.json | 1 + .../frames-v1/fixtures/pong.json | 1 + .../frames-v1/fixtures/quiet.json | 1 + .../frames-v1/fixtures/replay.json | 1 + .../frames-v1/fixtures/replay_end.json | 1 + .../frames-v1/fixtures/replay_req.json | 1 + .../frames-v1/fixtures/reseed.json | 1 + .../frames-v1/fixtures/resume.json | 1 + .../frames-v1/fixtures/roster.json | 1 + .../frames-v1/fixtures/secret.json | 1 + .../frames-v1/fixtures/seed.json | 1 + .../frames-v1/fixtures/speaker.json | 1 + .../frames-v1/fixtures/summary.json | 1 + .../frames-v1/fixtures/tool.json | 1 + .../frames-v1/fixtures/tool_result.json | 1 + .../frames-v1/fixtures/transcript.json | 1 + .../buzz-huddle-bridge/frames-v1/schema.json | 44 ++++ crates/buzz-huddle-bridge/src/frames.rs | 194 ++++++++++++++++++ crates/buzz-huddle-bridge/src/join.rs | 92 +++++++++ crates/buzz-huddle-bridge/src/lib.rs | 9 + 35 files changed, 403 insertions(+) create mode 100644 crates/buzz-huddle-bridge/Cargo.toml create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/awake.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/close.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/elevated.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/floor.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/handoff.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/meter.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/mute.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/notice.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/onset.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/open.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/opened.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/ping.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/pong.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/quiet.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/replay.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/replay_end.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/replay_req.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/reseed.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/resume.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/roster.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/secret.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/seed.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/speaker.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/summary.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/tool.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/tool_result.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/fixtures/transcript.json create mode 100644 crates/buzz-huddle-bridge/frames-v1/schema.json create mode 100644 crates/buzz-huddle-bridge/src/frames.rs create mode 100644 crates/buzz-huddle-bridge/src/join.rs create mode 100644 crates/buzz-huddle-bridge/src/lib.rs diff --git a/.github/workflows/_ci-rust.yml b/.github/workflows/_ci-rust.yml index 5bdc9e5ea49..703394b322d 100644 --- a/.github/workflows/_ci-rust.yml +++ b/.github/workflows/_ci-rust.yml @@ -156,6 +156,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..2c2365bf418 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" 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..e8c4d9ac358 --- /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. open..pong go PC to Talos; opened..close go Talos to PC. t_ms, start_ms, end_ms: session audio ms. from_ms, to_ms: unix ms. expires_at, until: unix seconds. span: per-session speaker span id. token: floor generation.", + "type": "object", + "required": ["v", "type"], + "properties": {"v": {"const": 1}}, + "$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"}}}}, + "event": {"type": "object", "required": ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"]} + }, + "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"}}}, "seed": {"$ref": "#/$defs/seed"}, "profile": {"type": "object", "required": ["voice", "persona", "aliases"], "properties": {"voice": {"type": "string"}, "persona": {"type": "string"}, "aliases": {"type": "array", "items": {"type": "string"}}}}}, "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"]}, + {"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..45bff56ce86 --- /dev/null +++ b/crates/buzz-huddle-bridge/src/frames.rs @@ -0,0 +1,194 @@ +//! 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. +//! `frames-v1/schema.json` and one fixture per frame in `frames-v1/fixtures/` are the contract +//! Talos copies. Times: `t_ms`, `start_ms` and `end_ms` are session audio ms; `from_ms` and +//! `to_ms` are unix ms; `expires_at` and `until` are unix seconds. + +use serde::{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; + +/// One text frame. 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")] +pub enum Frame { + // PC to Talos. + 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. + 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)] +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)] +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)] +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, refusing any other version. +pub fn parse(text: &str) -> Result { + let value: Value = serde_json::from_str(text).map_err(|e| format!("frame: {e}"))?; + if value.get("v").and_then(Value::as_u64) != Some(VERSION) { + return Err("frame: not v1".into()); + } + Frame::deserialize(value).map_err(|e| format!("frame: {e}")) +} + +/// Serialize one text frame with its `v`. +pub fn to_text(frame: &Frame) -> 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 + } + + #[test] + fn every_fixture_round_trips() { + let fixtures = fixtures(); + assert_eq!(fixtures.len(), 27, "one fixture per frame"); + for (name, value) in fixtures { + assert_eq!(value["type"], name.as_str(), "{name}: named after its type"); + let frame = parse(&value.to_string()).unwrap_or_else(|e| panic!("{name}: {e}")); + let back: Value = serde_json::from_str(&to_text(&frame)).unwrap(); + assert_eq!(back, value, "{name}: round trip"); + } + } + + /// The schema Talos copies names the same frames and fields as the fixtures. + #[test] + fn schema_matches_fixtures() { + let text = std::fs::read_to_string(format!("{DIR}/schema.json")).unwrap(); + let schema: Value = serde_json::from_str(&text).unwrap(); + let branches = schema["oneOf"].as_array().unwrap(); + let fixtures = fixtures(); + assert_eq!(branches.len(), fixtures.len()); + let keys = |v: &Value| -> BTreeSet { + match v { + Value::Object(m) => m.keys().filter(|k| *k != "v").cloned().collect(), + Value::Array(a) => a.iter().map(|k| k.as_str().unwrap().to_owned()).collect(), + _ => panic!("schema shape"), + } + }; + for (name, value) in fixtures { + let branch = branches + .iter() + .find(|b| b["properties"]["type"]["const"] == name.as_str()) + .unwrap_or_else(|| panic!("{name}: no schema branch")); + let declared = keys(&branch["properties"]); + assert_eq!( + keys(&value), + declared, + "{name}: fixture fields match the schema" + ); + assert_eq!( + keys(&branch["required"]), + declared, + "{name}: every field required" + ); + } + } + + #[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(Frame::Ping { t: 1 }) + ); + } +} diff --git a/crates/buzz-huddle-bridge/src/join.rs b/crates/buzz-huddle-bridge/src/join.rs new file mode 100644 index 00000000000..620df0c0183 --- /dev/null +++ b/crates/buzz-huddle-bridge/src/join.rs @@ -0,0 +1,92 @@ +//! 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 kind 22242 answer to the audio challenge. +pub fn auth_event( + keys: &Keys, + relay_url: &str, + challenge: &str, + owner_tag: Option<&Tag>, +) -> Result { + let mut tags = vec![ + Tag::parse(["relay", relay_url]).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. +pub async fn join( + request: Request, + parent: Uuid, + relay_url: &str, + keys: &Keys, + owner_tag: Option<&Tag>, +) -> Result { + let parent = parent.to_string(); + connect(request, Some(&parent), None, |challenge| async move { + let event = auth_event(keys, relay_url, &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()); + } +} diff --git a/crates/buzz-huddle-bridge/src/lib.rs b/crates/buzz-huddle-bridge/src/lib.rs new file mode 100644 index 00000000000..f111b106d6b --- /dev/null +++ b/crates/buzz-huddle-bridge/src/lib.rs @@ -0,0 +1,9 @@ +//! The huddle audio bridge between a relay audio room and one agent voice on Talos. +//! +//! [`join`] admits the agent to the room and [`frames`] is the v1 contract with Talos. Room +//! input, 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 join; From d542e86d36bb60542bcd432538e5d859c88e44c8 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Fri, 30 Oct 2026 06:15:25 -0500 Subject: [PATCH 18/22] fix(huddle-bridge): directional frames, strict schema, relay tag from the dialed request - frames-v1: split into ToTalos and ToPc, refuse unknown fields; the schema gets to_talos/to_pc defs with unevaluatedProperties false. Tests walk the schema for every fixture, check enums against Rust, and require every declared field. - The contract now states its clocks (secret windows widened by a skew margin and applied on receipt), that no live PCM goes between replay and replay_end, and the open/resume binding to the NIP-98 signer. - join: the NIP-42 relay tag comes from the request's origin, so it always names the host that issued the challenge; auth_event is private; the CryptoProvider precondition is documented. - just test-unit runs cargo test -p buzz-huddle-bridge on Linux. Co-Authored-By: Claude Opus 5.5 --- Justfile | 2 + .../buzz-huddle-bridge/frames-v1/schema.json | 72 ++--- crates/buzz-huddle-bridge/src/frames.rs | 289 +++++++++++++++--- crates/buzz-huddle-bridge/src/join.rs | 38 ++- 4 files changed, 311 insertions(+), 90 deletions(-) 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-huddle-bridge/frames-v1/schema.json b/crates/buzz-huddle-bridge/frames-v1/schema.json index e8c4d9ac358..1f58644a678 100644 --- a/crates/buzz-huddle-bridge/frames-v1/schema.json +++ b/crates/buzz-huddle-bridge/frames-v1/schema.json @@ -1,44 +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. open..pong go PC to Talos; opened..close go Talos to PC. t_ms, start_ms, end_ms: session audio ms. from_ms, to_ms: unix ms. expires_at, until: unix seconds. span: per-session speaker span id. token: floor generation.", - "type": "object", - "required": ["v", "type"], - "properties": {"v": {"const": 1}}, + "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"}}}}, - "event": {"type": "object", "required": ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"]} - }, - "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"}}}, "seed": {"$ref": "#/$defs/seed"}, "profile": {"type": "object", "required": ["voice", "persona", "aliases"], "properties": {"voice": {"type": "string"}, "persona": {"type": "string"}, "aliases": {"type": "array", "items": {"type": "string"}}}}}, "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"]}, - {"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"]} - ] + "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 index 45bff56ce86..74ff1362e74 100644 --- a/crates/buzz-huddle-bridge/src/frames.rs +++ b/crates/buzz-huddle-bridge/src/frames.rs @@ -1,22 +1,36 @@ //! 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. -//! `frames-v1/schema.json` and one fixture per frame in `frames-v1/fixtures/` are the contract -//! Talos copies. Times: `t_ms`, `start_ms` and `end_ms` are session audio ms; `from_ms` and -//! `to_ms` are unix ms; `expires_at` and `until` are unix seconds. +//! [`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::{Deserialize, Serialize}; +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; -/// One text frame. Kept one per line so it reads as the design's list. +/// 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")] -pub enum Frame { - // PC to Talos. +#[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), @@ -28,7 +42,13 @@ pub enum Frame { ToolResult { id: String, ok: bool, text: String }, Handoff { kind: HandoffKind, text: String }, Pong { t: u64 }, - // Talos to PC. +} + +/// 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 }, @@ -50,6 +70,7 @@ pub enum Frame { /// 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, @@ -57,6 +78,7 @@ pub struct Proofs { /// 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, @@ -64,6 +86,7 @@ pub struct Seed { /// 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, @@ -99,17 +122,18 @@ pub enum NoticeKind { Offline, } -/// Parse one text frame, refusing any other version. -pub fn parse(text: &str) -> Result { - let value: Value = serde_json::from_str(text).map_err(|e| format!("frame: {e}"))?; - if value.get("v").and_then(Value::as_u64) != Some(VERSION) { +/// 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()); } - Frame::deserialize(value).map_err(|e| format!("frame: {e}")) + T::deserialize(value).map_err(|e| format!("frame: {e}")) } /// Serialize one text frame with its `v`. -pub fn to_text(frame: &Frame) -> String { +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() @@ -136,59 +160,226 @@ mod tests { 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() { + 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 frame = parse(&value.to_string()).unwrap_or_else(|e| panic!("{name}: {e}")); - let back: Value = serde_json::from_str(&to_text(&frame)).unwrap(); + 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"); } - /// The schema Talos copies names the same frames and fields as the fixtures. + /// 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_matches_fixtures() { - let text = std::fs::read_to_string(format!("{DIR}/schema.json")).unwrap(); - let schema: Value = serde_json::from_str(&text).unwrap(); - let branches = schema["oneOf"].as_array().unwrap(); - let fixtures = fixtures(); - assert_eq!(branches.len(), fixtures.len()); - let keys = |v: &Value| -> BTreeSet { - match v { - Value::Object(m) => m.keys().filter(|k| *k != "v").cloned().collect(), - Value::Array(a) => a.iter().map(|k| k.as_str().unwrap().to_owned()).collect(), - _ => panic!("schema shape"), + 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); } - }; - for (name, value) in fixtures { + 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"] == name.as_str()) - .unwrap_or_else(|| panic!("{name}: no schema branch")); - let declared = keys(&branch["properties"]); - assert_eq!( - keys(&value), - declared, - "{name}: fixture fields match the schema" - ); - assert_eq!( - keys(&branch["required"]), - declared, - "{name}: every field required" - ); + .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!(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(Frame::Ping { t: 1 }) + parse::(r#"{"v":1,"type":"ping","t":1}"#), + Ok(ToPc::Ping { t: 1 }) ); } } diff --git a/crates/buzz-huddle-bridge/src/join.rs b/crates/buzz-huddle-bridge/src/join.rs index 620df0c0183..5733c642588 100644 --- a/crates/buzz-huddle-bridge/src/join.rs +++ b/crates/buzz-huddle-bridge/src/join.rs @@ -16,15 +16,26 @@ pub fn audio_request(relay_url: &str, huddle: Uuid) -> Result { .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. -pub fn auth_event( +fn auth_event( keys: &Keys, - relay_url: &str, + relay: &str, challenge: &str, owner_tag: Option<&Tag>, ) -> Result { let mut tags = vec![ - Tag::parse(["relay", relay_url]).map_err(|e| format!("relay tag: {e}"))?, + 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()); @@ -36,16 +47,19 @@ pub fn auth_event( /// 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, - relay_url: &str, 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_url, &challenge, owner_tag)?; + let event = auth_event(keys, &relay, &challenge, owner_tag)?; serde_json::to_value(&event).map_err(|e| format!("audio auth json: {e}")) }) .await @@ -89,4 +103,18 @@ mod tests { .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}"); + } + } } From 8f6a9ef1a7c5ffd0abe02910642ea79e814c1bd5 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Fri, 30 Oct 2026 05:54:07 -0500 Subject: [PATCH 19/22] feat(huddle-bridge): room input toward one voice Slice 3a, part 2 of 3 (stacked on the crate and frames-v1 commit, PR #94). Input turns relay room audio into what one agent voice may hear: - per-peer Opus decode with buzz-audio-client's PeerJitterBuffer; - the peer index to pubkey map from the relay's joined, roster and left messages, dropping a slot whose occupant changed; - a caller-supplied policy: the agent set (bot roles, trusted 30177 records, Talos, this agent) always wins, then the owner and the pubkeys the agent's author gate admits; everyone else is dropped before decode; - speech from decoded PCM energy, never the client-written level_dbov byte, with an onset frame for every admitted human so a quiet voice can wake; - one human at a time with a 300 ms hangover, a speaker frame at each change, continuous 20 ms PCM16 chunks while awake (silence when nobody holds the floor), and a send log mapping session ms to the peer so transcript deltas can be attributed; - 48 to 24 kHz by plain decimation for now; part 3 adds the half-band low-pass in front of it. Frames out are ToTalos (onset, speaker, roster). Tests feed real Opus frames: forged level bytes are ignored, agents and unadmitted peers are never heard, and the floor holds and the send log names each span. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-huddle-bridge/src/input.rs | 554 +++++++++++++++++++++++++ crates/buzz-huddle-bridge/src/lib.rs | 7 +- 2 files changed, 558 insertions(+), 3 deletions(-) create mode 100644 crates/buzz-huddle-bridge/src/input.rs diff --git a/crates/buzz-huddle-bridge/src/input.rs b/crates/buzz-huddle-bridge/src/input.rs new file mode 100644 index 00000000000..b59be56ecbd --- /dev/null +++ b/crates/buzz-huddle-bridge/src/input.rs @@ -0,0 +1,554 @@ +//! 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`); and one human at a time is forwarded, resampled to +//! 24 kHz, with a send log. +//! +//! The caller owns the socket and the clock: it feeds relay frames and control messages in, +//! calls [`Input::tick`] every 10 ms with unix ms, and sends what comes out to Talos. + +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 std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet, VecDeque}; + +const TICK_MS: u64 = 10; +const IDLE_MS: u64 = 500; +const CHUNK: usize = 480; // 20 ms at 24 kHz. +const SENT_CAP: usize = 4_096; + +/// 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`. + pub onset_ms: u64, + /// Silence before the forwarded human releases the floor. + pub hangover_ms: u64, +} + +impl Default for Tuning { + fn default() -> Self { + Self { + speech_dbov: -45, + onset_ms: 100, + hangover_ms: 300, + } + } +} + +/// 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 from_ms: u64, + pub to_ms: u64, +} + +struct Floor { + index: u8, + span: u64, +} + +struct Slot { + key: (String, u8), + jitter: PeerJitterBuffer, + last: Vec, + last_packet_ms: u64, + loud_ms: u64, + quiet_ms: u64, + talking: bool, + since_ms: u64, +} + +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, + roster: HashMap, + slots: HashMap, + floor: Option, + next_span: u64, + awake: bool, + session_ms: u64, + pending: Vec, + sent: VecDeque, +} + +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(); + } + + /// Replace the peer map from a relay snapshot (the initial `Joined::peers`). + pub fn set_roster(&mut self, peers: &[Peer]) { + self.roster = peers + .iter() + .map(|p| (p.index, (p.pubkey.clone(), p.epoch))) + .collect(); + self.prune(); + } + + /// Apply a relay control message: `joined` and `roster` carry the full snapshot. + pub fn on_control(&mut self, msg: &serde_json::Value) { + match msg["type"].as_str() { + Some("joined" | "roster") => match parse_peers(msg) { + Ok(peers) => self.set_roster(&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, + last: Vec::new(), + last_packet_ms: now_ms, + loud_ms: 0, + quiet_ms: 0, + talking: false, + since_ms: 0, + }) + } + }; + 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) { + if awake && !self.awake { + self.session_ms = 0; + self.sent.clear(); + } + self.awake = awake; + self.floor = None; + self.pending.clear(); + } + + /// 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(); + for slot in self.slots.values_mut() { + slot.last.clear(); + 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); + if let Some(pcm) = pcm { + // ponytail: plain decimation aliases above 12 kHz; the next part low-passes first. + slot.last = pcm + .iter() + .step_by(2) + .map(|s| (s.clamp(-1.0, 1.0) * 32_767.0) as i16) + .collect(); + } + if slot.hear(loud, now_ms, &t) { + out.push(Out::Frame(ToTalos::Onset { + pubkey: slot.key.0.clone(), + })); + } + } + if !self.awake { + return out; + } + if self + .floor + .as_ref() + .is_some_and(|f| !self.slots.get(&f.index).is_some_and(|s| s.talking)) + { + self.floor = None; + } + if self.floor.is_none() { + let next = self + .slots + .iter() + .filter(|(_, s)| s.talking) + .min_by_key(|(i, s)| (s.since_ms, **i)); + if let Some((&index, slot)) = next { + self.next_span += 1; + let floor = Floor { + index, + span: self.next_span, + }; + out.push(Out::Frame(ToTalos::Speaker { + span: floor.span, + pubkey: slot.key.0.clone(), + role: self.role(&slot.key.0), + echo: false, + t_ms: self.session_ms, + })); + self.floor = Some(floor); + } + } + // ponytail: the onset's first `onset_ms` isn't forwarded; the ring slice sends it. + let held = self.floor.as_ref().map(|f| (f.span, &self.slots[&f.index])); + let audio = match held { + Some((_, slot)) if !slot.last.is_empty() => slot.last.clone(), + _ => vec![0; CHUNK / 2], + }; + let from = self.session_ms; + self.session_ms += audio.len() as u64 * 1000 / 24_000; + if let Some((span, slot)) = held { + let pubkey = slot.key.0.clone(); + self.log_sent(Sent { + span, + role: self.role(&pubkey), + pubkey, + from_ms: from, + to_ms: self.session_ms, + }); + } + self.pending.extend(audio); + while self.pending.len() >= CHUNK { + out.push(Out::Pcm( + self.pending + .drain(..CHUNK) + .flat_map(|s| s.to_le_bytes()) + .collect(), + )); + } + out + } + + 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; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_audio_client::encoder::AudioEncoder; + use std::f32::consts::TAU; + + 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) + } + + 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(index, enc, amp, level) in + talkers.iter_mut().filter(|_| now.is_multiple_of(20)) + { + 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; + } + input.on_audio(&frame, *now); + } + out.extend(input.tick(*now)); + *now += TICK_MS; + } + out + } + + 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)); + } + + #[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; the roster follows the relay. + let peers = serde_json::json!([{"pubkey": OWNER, "peer_index": 1}, {"pubkey": AGENT, "peer_index": 3}, + {"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); + assert_eq!( + frames(&out)[0], + &ToTalos::Onset { + pubkey: GUEST.into() + } + ); + input.on_control(&serde_json::json!({"type": "left", "peer_index": 4})); + let roster = ToTalos::Roster { + humans: vec![OWNER.into()], + voices: vec![AGENT.into()], + }; + assert_eq!(input.roster_frame(), roster); + } + + #[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 + ); + } +} diff --git a/crates/buzz-huddle-bridge/src/lib.rs b/crates/buzz-huddle-bridge/src/lib.rs index f111b106d6b..9d9bd68fed8 100644 --- a/crates/buzz-huddle-bridge/src/lib.rs +++ b/crates/buzz-huddle-bridge/src/lib.rs @@ -1,9 +1,10 @@ //! The huddle audio bridge between a relay audio room and one agent voice on Talos. //! -//! [`join`] admits the agent to the room and [`frames`] is the v1 contract with Talos. Room -//! input, output, reconnect and the socket loop come in later slices. This crate loads no keys -//! and starts no tasks. +//! [`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; From b154ec8a8d13a7994fed0ccdba6012697e7e2417 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Fri, 30 Oct 2026 07:09:25 -0500 Subject: [PATCH 20/22] feat(huddle-bridge): ring, secret windows and half-duplex on speakers Slice 3a, part 3 of 3 (stacked on the room input commit, part 2). - 48 to 24 kHz now goes through a 31-tap half-band low-pass in front of the decimation, so nothing above 12 kHz aliases into speech. - Each admitted peer keeps an 8 s ring of 24 kHz audio. replay() returns a span of it for a wake, and a new floor starts with the onset's last 100 ms from the ring, so the first syllable is not clipped. - The ring is bounded by age on every tick (the count cap stays as a backstop), and replay() takes the caller's clock too, so sparse ticks or a late replay never hand out audio older than 8 s. An entry stamped ahead of the clock (the clock stepped back) is dropped too. - Audio heard while half-duplex suppression is on never enters the ring, so a replay or preroll can't carry the voice's own echo. - secret {from_ms, to_ms} is widened by secret_skew_ms (1 s to start) on both sides and starts dropping on receipt, even if from_ms is still ahead on the PC's clock, as the frames-v1 docs promise. The window leaves every ring, the <=10 ms not yet sent is silenced, and audio inside it is neither recorded nor forwarded. - The desktop's route file {isolated, huddle, updated_at} is read for one huddle; a missing file, bad JSON or another huddle means not isolated. It names the owner's desktop only while the owner has one peer in the room; every other peer counts as not isolated. - Half-duplex: while the voice's own output plays plus a tail (300 ms for the desktop, 600 ms elsewhere until probe P measures the phone), a non-isolated peer gets no onset and no forwarded audio, and a span that starts in that window carries echo: true for its whole length. Tests: sparse ticks never replay audio older than the ring, echo-time audio is never in the ring, a secret window is widened by the skew and starts on receipt, and the resampler passes 1 kHz and stops 20 kHz. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-huddle-bridge/src/input.rs | 346 +++++++++++++++++++++++-- 1 file changed, 328 insertions(+), 18 deletions(-) diff --git a/crates/buzz-huddle-bridge/src/input.rs b/crates/buzz-huddle-bridge/src/input.rs index b59be56ecbd..a9ec83df25f 100644 --- a/crates/buzz-huddle-bridge/src/input.rs +++ b/crates/buzz-huddle-bridge/src/input.rs @@ -1,7 +1,9 @@ //! 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`); and one human at a time is forwarded, resampled to -//! 24 kHz, with a send log. +//! (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 with unix ms, and sends what comes out to Talos. @@ -10,13 +12,18 @@ 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; /// 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. @@ -38,10 +45,16 @@ impl Policy { 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`. + /// 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. + pub desktop_tail_ms: u64, + /// Echo tail for every other peer. ponytail: a guess until probe P measures the phone. + pub remote_tail_ms: u64, + /// Widening on both sides of each `secret` window, for clock skew with Talos. + pub secret_skew_ms: u64, } impl Default for Tuning { @@ -50,10 +63,40 @@ impl Default for Tuning { speech_dbov: -45, onset_ms: 100, hangover_ms: 300, + desktop_tail_ms: 300, + remote_tail_ms: 600, + secret_skew_ms: 1_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}`, for `huddle`. A missing or +/// unreadable file, or one naming another huddle, means not here and not isolated. +pub fn read_route(path: &Path, huddle: &str) -> Route { + #[derive(Deserialize)] + struct File { + #[serde(default)] + isolated: bool, + huddle: Option, + } + let file: Option = std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()); + file.filter(|f| f.huddle.as_deref() == Some(huddle)) + .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. @@ -69,6 +112,7 @@ pub struct Sent { pub span: u64, pub pubkey: String, pub role: Role, + pub echo: bool, pub from_ms: u64, pub to_ms: u64, } @@ -76,17 +120,21 @@ pub struct Sent { 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, } impl Slot { @@ -118,6 +166,7 @@ impl Slot { pub struct Input { policy: Policy, tuning: Tuning, + route: Route, roster: HashMap, slots: HashMap, floor: Option, @@ -126,6 +175,8 @@ pub struct Input { session_ms: u64, pending: Vec, sent: VecDeque, + output_until_ms: u64, + secret: Vec>, } impl Input { @@ -142,6 +193,10 @@ impl Input { 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 = peers @@ -192,12 +247,15 @@ impl Input { 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, }) } }; @@ -222,6 +280,41 @@ impl Input { 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, + /// so skew only widens it. Its audio leaves every ring, and nothing inside it is recorded + /// or forwarded. + 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); + self.secret.push(window); + } + + /// One peer's ring audio in unix ms `from_ms..to_ms`, as PCM16 LE at 24 kHz. Audio older + /// than the ring at `now_ms` is never returned, even if no tick has evicted it yet. + pub fn replay(&self, index: u8, from_ms: u64, to_ms: u64, now_ms: u64) -> Vec { + let Some(slot) = self.slots.get(&index) else { + return Vec::new(); + }; + let ring = slot + .ring + .iter() + .filter(|(t, _)| (from_ms..to_ms).contains(t) && fresh(*t, now_ms)); + ring.flat_map(|(_, pcm)| pcm.iter().flat_map(|s| s.to_le_bytes())) + .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 { @@ -247,8 +340,18 @@ impl Input { pub fn tick(&mut self, now_ms: u64) -> Vec { let mut out = Vec::new(); let t = self.tuning.clone(); + self.secret.retain(|w| w.end > now_ms); + let secret = self.secret.iter().any(|w| w.contains(&now_ms)); + 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 { @@ -259,15 +362,27 @@ impl Input { 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 { - // ponytail: plain decimation aliases above 12 kHz; the next part low-passes first. - slot.last = pcm - .iter() - .step_by(2) - .map(|s| (s.clamp(-1.0, 1.0) * 32_767.0) as i16) - .collect(); + 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(); + } + } } - if slot.hear(loud, now_ms, &t) { + if slot.hear(loud, now_ms, &t) && !slot.echo { out.push(Out::Frame(ToTalos::Onset { pubkey: slot.key.0.clone(), })); @@ -283,6 +398,7 @@ impl Input { { self.floor = None; } + let mut preroll = false; if self.floor.is_none() { let next = self .slots @@ -294,31 +410,44 @@ impl Input { 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: false, + echo: floor.echo, t_ms: self.session_ms, })); self.floor = Some(floor); + preroll = true; } } - // ponytail: the onset's first `onset_ms` isn't forwarded; the ring slice sends it. - let held = self.floor.as_ref().map(|f| (f.span, &self.slots[&f.index])); - let audio = match held { - Some((_, slot)) if !slot.last.is_empty() => slot.last.clone(), - _ => vec![0; CHUNK / 2], + 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((span, slot)) = held { - let pubkey = slot.key.0.clone(); + 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, }); @@ -370,12 +499,65 @@ impl Input { } } +/// 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. +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"; @@ -551,4 +733,132 @@ mod tests { 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 ring = if echo { 20 } else { 180 }; + assert_eq!(input.replay(1, start, now, now).len(), ring * CHUNK); + } + } + + #[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!(input.replay(1, T0, now, now).len(), 300 * CHUNK); + // 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!(input.replay(1, T0, now, now).len(), 90 * CHUNK); + assert!(input.replay(1, T0 + 500, T0 + 2_600, now).is_empty()); + // 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) && input.replay(1, start, now, now).is_empty()); + assert!( + voiced(&run(&mut input, &mut now, 200, &mut owner)), + "after the window" + ); + } + + #[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!( + input.replay(1, 0, u64::MAX, T0 + RING_MS + 250).len(), + 24 * CHUNK + ); + assert!(input.replay(1, 0, u64::MAX, T0 + RING_MS + 490).is_empty()); + // One late tick evicts by age, where a count bound would have kept all 50 entries. + input.tick(T0 + 20_000); + assert!(input.slots[&1].ring.iter().all(|(t, _)| *t == T0 + 20_000)); + } + + #[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(20_000.0) < 0.01); + } + + #[test] + fn route_file_counts_only_for_this_huddle() { + let path = + std::env::temp_dir().join(format!("huddle-bridge-route-{}.json", std::process::id())); + let read = |text: &str| { + std::fs::write(&path, text).unwrap(); + read_route(&path, H) + }; + let on = Route { + here: true, + isolated: true, + }; + assert_eq!( + read(&format!( + r#"{{"isolated":true,"huddle":"{H}","updated_at":1}}"# + )), + on + ); + assert_eq!( + read(r#"{"isolated":true,"huddle":"other","updated_at":1}"#), + Route::default() + ); + assert_eq!(read(r#"{"isolated":true}"#), Route::default()); + assert_eq!(read("not json"), Route::default()); + std::fs::remove_file(&path).unwrap(); + assert_eq!(read_route(&path, H), Route::default()); + } } From 1ec091c4105c70633fab0ddc9dfc2f8ddc2ac60c Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Fri, 30 Oct 2026 07:35:12 -0500 Subject: [PATCH 21/22] fix(huddle-bridge): joined adds peers, a repeated awake keeps the clock From another pod the relay's `joined` lists only the newcomer, so treating it as a full snapshot dropped every other peer, the owner included. Now `joined` adds or replaces its peers and only `roster` replaces the map. A second `awake` while awake cleared up to 10 ms of audio the session clock had already counted and restarted the span. It is now a no-op. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-huddle-bridge/src/input.rs | 66 ++++++++++++++++++-------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/crates/buzz-huddle-bridge/src/input.rs b/crates/buzz-huddle-bridge/src/input.rs index b59be56ecbd..629dfd2cc6a 100644 --- a/crates/buzz-huddle-bridge/src/input.rs +++ b/crates/buzz-huddle-bridge/src/input.rs @@ -144,18 +144,24 @@ impl Input { /// Replace the peer map from a relay snapshot (the initial `Joined::peers`). pub fn set_roster(&mut self, peers: &[Peer]) { - self.roster = peers - .iter() - .map(|p| (p.index, (p.pubkey.clone(), p.epoch))) - .collect(); + 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: `joined` and `roster` carry the full snapshot. + /// 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("joined" | "roster") => match parse_peers(msg) { - Ok(peers) => self.set_roster(&peers), + 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") => { @@ -213,7 +219,11 @@ impl Input { /// 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) { - if awake && !self.awake { + // 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(); } @@ -477,6 +487,15 @@ mod tests { .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] @@ -491,23 +510,28 @@ mod tests { ]; 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; the roster follows the relay. - let peers = serde_json::json!([{"pubkey": OWNER, "peer_index": 1}, {"pubkey": AGENT, "peer_index": 3}, - {"pubkey": GUEST, "peer_index": 4, "epoch": 1}]); + // 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); - assert_eq!( - frames(&out)[0], - &ToTalos::Onset { - pubkey: GUEST.into() - } + 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" ); - input.on_control(&serde_json::json!({"type": "left", "peer_index": 4})); - let roster = ToTalos::Roster { - humans: vec![OWNER.into()], - voices: vec![AGENT.into()], + 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(), }; - assert_eq!(input.roster_frame(), roster); + 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] From bed9669ca7d862c1d0315d33f535f04496996b72 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Fri, 30 Oct 2026 07:47:21 -0500 Subject: [PATCH 22/22] fix(huddle-bridge): drop audio queued through stalls, fail the route closed, replay on the clock Review fixes for the ring, secret windows and half-duplex: - A tick gap over 50 ms, a clock step back or a secret window's end now restarts every peer's jitter buffer and filter, so audio that arrived inside a window or an echo is never played out late. - A secret window not yet over holds even if the clock steps back. - read_route takes now and refuses a file more than 15 s old (three of the desktop's heartbeats). It still needs `huddle`, so a file from another huddle or without one counts as not isolated. - replay() answers replay_req itself: replay, PCM, replay_end. It pads pending live audio to a chunk first, counts the replay on the session clock and in the send log, and sends nothing while the voice is quiet. - A talker in its echo, or one with no pause for floor_cap_ms (30 s), yields the floor to any other talker. An onset that fell in the echo goes out once the echo clears. The previous commit said nothing above 12 kHz aliases. The half-band filter takes about 30 dB off 14 kHz and 50 dB off 16 kHz and up, and the test now checks that. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-huddle-bridge/src/input.rs | 367 +++++++++++++++++++------ 1 file changed, 284 insertions(+), 83 deletions(-) diff --git a/crates/buzz-huddle-bridge/src/input.rs b/crates/buzz-huddle-bridge/src/input.rs index b1d4972c751..3ddec9861f2 100644 --- a/crates/buzz-huddle-bridge/src/input.rs +++ b/crates/buzz-huddle-bridge/src/input.rs @@ -6,7 +6,8 @@ //! 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 with unix ms, and sends what comes out to Talos. +//! 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}; @@ -24,6 +25,8 @@ 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. @@ -49,12 +52,16 @@ pub struct Tuning { 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. + /// 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. ponytail: a guess until probe P measures the phone. + /// 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 { @@ -66,6 +73,7 @@ impl Default for Tuning { desktop_tail_ms: 300, remote_tail_ms: 600, secret_skew_ms: 1_000, + floor_cap_ms: 30_000, } } } @@ -78,19 +86,22 @@ pub struct Route { pub isolated: bool, } -/// Read the desktop's route file, `{isolated, huddle, updated_at}`, for `huddle`. A missing or -/// unreadable file, or one naming another huddle, means not here and not isolated. -pub fn read_route(path: &Path, huddle: &str) -> Route { +/// 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 { - #[serde(default)] isolated: bool, - huddle: Option, + huddle: String, + updated_at: u64, } let file: Option = std::fs::read(path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()); - file.filter(|f| f.huddle.as_deref() == Some(huddle)) + 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, @@ -135,6 +146,8 @@ struct Slot { talking: bool, since_ms: u64, echo: bool, + /// An onset not yet sent because it fell in the echo. + onset: bool, } impl Slot { @@ -177,6 +190,7 @@ pub struct Input { sent: VecDeque, output_until_ms: u64, secret: Vec>, + last_tick_ms: Option, } impl Input { @@ -262,6 +276,7 @@ impl Input { talking: false, since_ms: 0, echo: false, + onset: false, }) } }; @@ -296,9 +311,10 @@ impl Input { } /// 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, - /// so skew only widens it. Its audio leaves every ring, and nothing inside it is recorded - /// or forwarded. + /// 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 = @@ -308,20 +324,58 @@ impl Input { } // Up to 10 ms not yet sent: silence it, keeping the session clock. self.pending.fill(0); - self.secret.push(window); + if window.end > now_ms { + self.secret.push(window); + } } - /// One peer's ring audio in unix ms `from_ms..to_ms`, as PCM16 LE at 24 kHz. Audio older - /// than the ring at `now_ms` is never returned, even if no tick has evicted it yet. - pub fn replay(&self, index: u8, from_ms: u64, to_ms: u64, now_ms: u64) -> Vec { - let Some(slot) = self.slots.get(&index) else { - return Vec::new(); - }; - let ring = slot - .ring - .iter() - .filter(|(t, _)| (from_ms..to_ms).contains(t) && fresh(*t, now_ms)); - ring.flat_map(|(_, pcm)| pcm.iter().flat_map(|s| s.to_le_bytes())) + /// 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() } @@ -350,8 +404,18 @@ impl Input { 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); - let secret = self.secret.iter().any(|w| w.contains(&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() @@ -392,7 +456,11 @@ impl Input { } } } - if slot.hear(loud, now_ms, &t) && !slot.echo { + // 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(), })); @@ -401,11 +469,17 @@ impl Input { if !self.awake { return out; } - if self - .floor - .as_ref() - .is_some_and(|f| !self.slots.get(&f.index).is_some_and(|s| s.talking)) - { + // 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; @@ -414,7 +488,7 @@ impl Input { .slots .iter() .filter(|(_, s)| s.talking) - .min_by_key(|(i, s)| (s.since_ms, **i)); + .min_by_key(|(i, s)| (demoted(s), s.since_ms, **i)); if let Some((&index, slot)) = next { self.next_span += 1; let floor = Floor { @@ -463,15 +537,27 @@ impl Input { }); } 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 { - out.push(Out::Pcm( - self.pending - .drain(..CHUNK) - .flat_map(|s| s.to_le_bytes()) - .collect(), - )); + let chunk = self.pending.drain(..CHUNK).flat_map(|s| s.to_le_bytes()); + out.push(Out::Pcm(chunk.collect())); } - out + } + + /// 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 { @@ -515,7 +601,8 @@ 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. +/// 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, @@ -599,21 +686,26 @@ mod tests { 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(index, enc, amp, level) in - talkers.iter_mut().filter(|_| now.is_multiple_of(20)) - { - 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; - } - input.on_audio(&frame, *now); + 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; @@ -621,6 +713,10 @@ mod tests { 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 }) @@ -783,11 +879,105 @@ mod tests { 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 ring = if echo { 20 } else { 180 }; - assert_eq!(input.replay(1, start, now, now).len(), ring * CHUNK); + 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(); @@ -799,11 +989,11 @@ mod tests { .all(|o| matches!(o, Out::Frame(ToTalos::Onset { .. }))), "quiet: onset only" ); - assert_eq!(input.replay(1, T0, now, now).len(), 300 * CHUNK); + 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!(input.replay(1, T0, now, now).len(), 90 * CHUNK); - assert!(input.replay(1, T0 + 500, T0 + 2_600, now).is_empty()); + 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); @@ -811,11 +1001,20 @@ mod tests { 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) && input.replay(1, start, now, now).is_empty()); + 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] @@ -824,14 +1023,17 @@ mod tests { 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!( - input.replay(1, 0, u64::MAX, T0 + RING_MS + 250).len(), - 24 * CHUNK - ); - assert!(input.replay(1, 0, u64::MAX, T0 + RING_MS + 490).is_empty()); - // One late tick evicts by age, where a count bound would have kept all 50 entries. - input.tick(T0 + 20_000); - assert!(input.slots[&1].ring.iter().all(|(t, _)| *t == T0 + 20_000)); + 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] @@ -855,34 +1057,33 @@ mod tests { 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(20_000.0) < 0.01); + 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() { + 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: &str| { + let read = |text: String| { std::fs::write(&path, text).unwrap(); - read_route(&path, H) + 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(&format!( - r#"{{"isolated":true,"huddle":"{H}","updated_at":1}}"# - )), - on - ); - assert_eq!( - read(r#"{"isolated":true,"huddle":"other","updated_at":1}"#), - Route::default() - ); - assert_eq!(read(r#"{"isolated":true}"#), Route::default()); - assert_eq!(read("not json"), Route::default()); + 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), Route::default()); + assert_eq!(read_route(&path, H, T0), Route::default()); } }