Skip to content

Commit 65e8643

Browse files
wpfleger96Hayt
andauthored
feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form `<name>/<version>` on the upgrade request. The version comes from the build. | Client | User-Agent | |---|---| | `buzz-acp` | `buzz-acp/<crate version>` | | `buzz-cli` | `buzz-cli/<crate version>` | | `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` | | `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` | | `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` | | mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` | - **`buzz-ws-client`**: every connect path sends the default. A `User-Agent` in the caller's `connect_with_headers` headers replaces it, so other consumers can identify themselves without any new API. `buzz-cli` uses this to send its own name. - **`buzz-audio-client`**: keeps a `User-Agent` already on the host-supplied request, and adds its default otherwise. - **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`, which also covers the huddle transport. Startup waits up to 500 ms for the app version from `package_info_plus` before the app runs, so the first socket carries it; if the lookup fails or times out, the value is `Buzz-Mobile`. Each client has a test asserting the header on the upgrade request. --------- Signed-off-by: Will Pfleger <[email protected]> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
1 parent 31fa531 commit 65e8643

12 files changed

Lines changed: 300 additions & 18 deletions

File tree

‎Justfile‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,8 @@ test-unit:
423423
# never dialed). Keep the Cargo fallback in step.
424424
cargo nextest run -p buzz-relay-mesh
425425
cargo nextest run -p buzz-cli
426+
cargo nextest run -p buzz-ws-client
427+
cargo nextest run -p buzz-pairing-cli
426428
# buzz-sdk builder/validation unit tests: pure event-builder and input
427429
# validation (e.g. the canvas writer-discipline/skew guard and the
428430
# canvas_write_survived predicate), no infra. `--lib` runs all unit

‎crates/buzz-acp/src/relay.rs‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag};
133133
use serde_json::{json, Value};
134134
use tokio::sync::mpsc;
135135
use tokio::time::timeout;
136+
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
137+
use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderValue};
136138
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
137139
use tracing::{debug, info, warn};
138140
use uuid::Uuid;
@@ -4204,6 +4206,9 @@ where
42044206
Err(last_err.unwrap_or(RelayError::ConnectionClosed))
42054207
}
42064208

4209+
/// `User-Agent` sent on every relay WebSocket upgrade.
4210+
const RELAY_USER_AGENT: &str = concat!("buzz-acp/", env!("CARGO_PKG_VERSION"));
4211+
42074212
/// Perform a single WebSocket connect + NIP-42 auth handshake.
42084213
///
42094214
/// Returns `(ws, buffer)` on success.
@@ -4216,7 +4221,14 @@ async fn do_connect(
42164221
.parse::<url::Url>()
42174222
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
42184223

4219-
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
4224+
let mut request = parsed
4225+
.as_str()
4226+
.into_client_request()
4227+
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
4228+
request
4229+
.headers_mut()
4230+
.insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT));
4231+
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
42204232
.await
42214233
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
42224234
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
@@ -6375,6 +6387,34 @@ mod tests {
63756387
/// WebSocket endpoint, and tungstenite returns `Error::Http` (non-101
63766388
/// response) or `Error::Url(UnsupportedUrlScheme)` depending on how far
63776389
/// the handshake gets. Either way it must not be retried.
6390+
/// The upgrade `do_connect()` sends names buzz-acp and its version.
6391+
#[allow(clippy::result_large_err)] // tungstenite's handshake callback type
6392+
#[tokio::test]
6393+
async fn do_connect_sends_user_agent() {
6394+
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
6395+
6396+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6397+
let url = format!("ws://{}", listener.local_addr().unwrap());
6398+
let server = tokio::spawn(async move {
6399+
let (stream, _) = listener.accept().await.unwrap();
6400+
let mut received = None;
6401+
let _ws = tokio_tungstenite::accept_hdr_async(
6402+
stream,
6403+
|request: &Request, response: Response| {
6404+
received = request.headers().get(USER_AGENT).cloned();
6405+
Ok(response)
6406+
},
6407+
)
6408+
.await
6409+
.unwrap();
6410+
received
6411+
});
6412+
// The server hangs up after the upgrade, so the AUTH handshake fails.
6413+
let _ = do_connect(&url, &nostr::Keys::generate(), None).await;
6414+
assert_eq!(server.await.unwrap().unwrap(), RELAY_USER_AGENT);
6415+
assert!(RELAY_USER_AGENT.starts_with("buzz-acp/"));
6416+
}
6417+
63786418
#[tokio::test]
63796419
async fn do_connect_wrong_scheme_is_terminal() {
63806420
let keys = nostr::Keys::generate();

‎crates/buzz-audio-client/src/connection.rs‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ use serde_json::{json, Value};
55
use std::{collections::BTreeSet, future::Future, time::Duration};
66
use tokio_tungstenite::{
77
connect_async_tls_with_config,
8-
tungstenite::{client::IntoClientRequest, protocol::WebSocketConfig, Message},
8+
tungstenite::{
9+
client::IntoClientRequest,
10+
http::{header::USER_AGENT, HeaderValue},
11+
protocol::WebSocketConfig,
12+
Message,
13+
},
914
Connector, MaybeTlsStream, WebSocketStream,
1015
};
1116

@@ -96,11 +101,15 @@ async fn control(socket: &mut AudioSocket) -> Result<Value, String> {
96101
}
97102
}
98103

104+
/// `User-Agent` sent on the upgrade unless the request already carries one.
105+
const DEFAULT_USER_AGENT: &str = concat!("buzz-audio-client/", env!("CARGO_PKG_VERSION"));
106+
99107
/// Connect and authenticate using a host-provided asynchronous signer.
100108
///
101109
/// The host validates the destination before calling, and constructs the NIP-42
102110
/// event (including its exact relay tag) from the challenge. `request` is the
103-
/// audio URL, or a request carrying host-supplied upgrade headers. No private key is
111+
/// audio URL, or a request carrying host-supplied upgrade headers; without a
112+
/// `User-Agent` it sends `buzz-audio-client/<version>`. No private key is
104113
/// accepted. Dropping this future cancels connection/admission; the entire
105114
/// operation is bounded to 15 seconds, including signing.
106115
pub async fn connect<F, Fut>(
@@ -117,6 +126,13 @@ where
117126
let config = WebSocketConfig::default()
118127
.max_message_size(Some(65_536))
119128
.max_frame_size(Some(65_536));
129+
let mut request = request
130+
.into_client_request()
131+
.map_err(|e| format!("audio WS connect failed: {e}"))?;
132+
request
133+
.headers_mut()
134+
.entry(USER_AGENT)
135+
.or_insert(HeaderValue::from_static(DEFAULT_USER_AGENT));
120136
let (mut socket, _) =
121137
connect_async_tls_with_config(request, Some(config), false, connector)
122138
.await

‎crates/buzz-audio-client/tests/connection.rs‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,11 @@ async fn request_headers_reach_the_upgrade() {
149149
let (stream, _) = listener.accept().await.unwrap();
150150
let (seen, header) = oneshot::channel();
151151
let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| {
152-
let _ = seen.send(request.headers().get("authorization").cloned());
152+
let headers = request.headers();
153+
let _ = seen.send((
154+
headers.get("authorization").cloned(),
155+
headers.get("user-agent").cloned(),
156+
));
153157
Ok(response)
154158
})
155159
.await
@@ -182,8 +186,11 @@ async fn request_headers_reach_the_upgrade() {
182186
})
183187
.await
184188
.unwrap();
185-
assert_eq!(
186-
server.await.unwrap().unwrap().to_str().unwrap(),
187-
"Bearer upgrade-token"
188-
);
189+
let (authorization, user_agent) = server.await.unwrap();
190+
assert_eq!(authorization.unwrap(), "Bearer upgrade-token");
191+
let user_agent = user_agent.unwrap();
192+
assert!(user_agent
193+
.to_str()
194+
.unwrap()
195+
.starts_with("buzz-audio-client/"));
189196
}

‎crates/buzz-cli/src/client.rs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use crate::error::CliError;
1212

1313
/// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport).
1414
const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity";
15+
/// `User-Agent` sent on every WebSocket upgrade.
16+
const USER_AGENT: &str = concat!("buzz-cli/", env!("CARGO_PKG_VERSION"));
1517

1618
/// Descriptor returned by the relay after a successful upload.
1719
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -704,10 +706,14 @@ impl BuzzClient {
704706
})
705707
}
706708

707-
/// The NIP-FI assertion as WebSocket upgrade headers (empty without an
708-
/// evidence source).
709+
/// The WebSocket upgrade headers: the CLI's `User-Agent`, and the NIP-FI
710+
/// assertion when there is an evidence source.
709711
async fn upgrade_headers(&self) -> Result<buzz_ws_client::HeaderMap, CliError> {
710712
let mut headers = buzz_ws_client::HeaderMap::new();
713+
headers.insert(
714+
"user-agent",
715+
buzz_ws_client::HeaderValue::from_static(USER_AGENT),
716+
);
711717
if let Some(assertion) = self.evidence().await? {
712718
let mut value =
713719
buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}"))
@@ -4095,6 +4101,20 @@ mod ws_deadline_tests {
40954101

40964102
pub(super) type Log = Arc<std::sync::Mutex<Vec<String>>>;
40974103

4104+
/// Every upgrade names the CLI and its version.
4105+
#[tokio::test]
4106+
async fn upgrade_headers_carry_the_cli_user_agent() {
4107+
let keys = Keys::generate();
4108+
let c = BuzzClient::new(
4109+
"http://127.0.0.1:1".into(),
4110+
Credentials::from_signer(Arc::new(keys.clone()), keys.public_key()),
4111+
)
4112+
.unwrap();
4113+
let headers = c.upgrade_headers().await.unwrap();
4114+
assert_eq!(headers.get("user-agent").unwrap(), super::USER_AGENT);
4115+
assert!(super::USER_AGENT.starts_with("buzz-cli/"));
4116+
}
4117+
40984118
/// A sign-in signature that comes back after the relay's 5 s deadline
40994119
/// reconnects and signs the new challenge, never the old one.
41004120
#[tokio::test]

‎crates/buzz-pairing-cli/src/main.rs‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ use clap::{Parser, Subcommand};
2727
use futures_util::{SinkExt, StreamExt};
2828
use nostr::{Event, EventBuilder, Keys, RelayUrl, SecretKey, ToBech32};
2929
use tokio::time::timeout;
30+
use tokio_tungstenite::tungstenite::{
31+
client::IntoClientRequest,
32+
handshake::client::Request,
33+
http::{header::USER_AGENT, HeaderValue},
34+
};
3035
use tokio_tungstenite::{connect_async, tungstenite::Message};
3136
use zeroize::Zeroizing;
3237

@@ -133,7 +138,7 @@ async fn cmd_source(relay_url: String, nsec: Option<String>) -> Result<(), CliEr
133138

134139
// Connect to relay and handle NIP-42 auth if required.
135140
// Auth uses the session's ephemeral keys so the relay accepts our events.
136-
let (ws, _) = connect_async(&relay_url).await?;
141+
let (ws, _) = connect_async(relay_request(&relay_url)?).await?;
137142
let (mut write, mut read) = ws.split();
138143
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
139144

@@ -235,7 +240,7 @@ async fn cmd_target(relay_override: Option<String>, show_secret: bool) -> Result
235240
let (mut session, offer_event) = PairingSession::new_target(&qr)?;
236241

237242
// Connect to relay and handle NIP-42 auth if required.
238-
let (ws, _) = connect_async(&relay_url).await?;
243+
let (ws, _) = connect_async(relay_request(&relay_url)?).await?;
239244
let (mut write, mut read) = ws.split();
240245
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
241246

@@ -629,3 +634,27 @@ fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> {
629634
.try_into()
630635
.map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'")))
631636
}
637+
638+
/// `User-Agent` sent on the relay WebSocket upgrade.
639+
const RELAY_USER_AGENT: &str = concat!("buzz-pairing-cli/", env!("CARGO_PKG_VERSION"));
640+
641+
/// The relay upgrade request for `url`, naming this tool and its version.
642+
fn relay_request(url: &str) -> Result<Request, tokio_tungstenite::tungstenite::Error> {
643+
let mut request = url.into_client_request()?;
644+
request
645+
.headers_mut()
646+
.insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT));
647+
Ok(request)
648+
}
649+
650+
#[cfg(test)]
651+
mod tests {
652+
use super::*;
653+
654+
#[test]
655+
fn relay_request_carries_user_agent() {
656+
let request = relay_request("wss://relay.example.com").unwrap();
657+
assert_eq!(request.headers()[USER_AGENT], RELAY_USER_AGENT);
658+
assert!(RELAY_USER_AGENT.starts_with("buzz-pairing-cli/"));
659+
}
660+
}

‎crates/buzz-ws-client/src/connection.rs‎

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ use nostr::{Event, NostrSigner, Tag};
66
use serde_json::{json, Value};
77
use tokio::time::timeout;
88
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
9-
use tokio_tungstenite::tungstenite::http::HeaderMap;
9+
use tokio_tungstenite::tungstenite::handshake::client::Request;
10+
use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderMap, HeaderValue};
1011
use tokio_tungstenite::{
1112
client_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream,
1213
};
@@ -40,6 +41,19 @@ async fn phase<T>(
4041
.map_err(|_| WsClientError::Timeout)?
4142
}
4243

44+
/// `User-Agent` sent on the upgrade unless the caller's headers supply one.
45+
const DEFAULT_USER_AGENT: &str = concat!("buzz-ws-client/", env!("CARGO_PKG_VERSION"));
46+
47+
/// The upgrade request for `url`: the default `User-Agent`, then `headers`.
48+
fn upgrade_request(url: &url::Url, headers: HeaderMap) -> Result<Request, WsClientError> {
49+
let mut request = url.as_str().into_client_request()?;
50+
request
51+
.headers_mut()
52+
.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT));
53+
request.headers_mut().extend(headers);
54+
Ok(request)
55+
}
56+
4357
/// A NIP-42-capable WebSocket connection to a Nostr relay.
4458
pub struct NostrWsConnection {
4559
ws: WsStream,
@@ -85,7 +99,8 @@ impl NostrWsConnection {
8599
/// [`Self::connect`], sending `headers` on the WebSocket upgrade request.
86100
///
87101
/// The headers are added after the handshake headers, so supplying a handshake
88-
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade.
102+
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade. A
103+
/// `User-Agent` among them replaces the default `buzz-ws-client/<version>`.
89104
pub async fn connect_with_headers(
90105
url: &str,
91106
headers: HeaderMap,
@@ -94,8 +109,7 @@ impl NostrWsConnection {
94109
.parse::<url::Url>()
95110
.map_err(|e| WsClientError::Url(e.to_string()))?;
96111

97-
let mut request = parsed.as_str().into_client_request()?;
98-
request.headers_mut().extend(headers);
112+
let request = upgrade_request(&parsed, headers)?;
99113

100114
let (ws, _response) = connect_async(request)
101115
.await
@@ -117,7 +131,7 @@ impl NostrWsConnection {
117131
let parsed = url
118132
.parse::<url::Url>()
119133
.map_err(|e| WsClientError::Url(e.to_string()))?;
120-
let request = parsed.as_str().into_client_request()?;
134+
let request = upgrade_request(&parsed, HeaderMap::new())?;
121135
let tcp = tokio::net::TcpStream::connect(addr)
122136
.await
123137
.map_err(|e| WsClientError::WebSocket(e.into()))?;
@@ -426,7 +440,6 @@ mod tests {
426440
#[tokio::test]
427441
async fn connect_with_headers_sends_headers_on_upgrade() {
428442
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
429-
use tokio_tungstenite::tungstenite::http::HeaderValue;
430443

431444
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
432445
let url = format!("ws://{}", listener.local_addr().unwrap());
@@ -460,6 +473,56 @@ mod tests {
460473
);
461474
}
462475

476+
/// The `User-Agent` a relay sees on an upgrade sent with `headers`, through
477+
/// `connect_with_headers` or, with `via_addr`, `connect_to_addr`.
478+
#[allow(clippy::result_large_err)]
479+
async fn upgrade_user_agent(headers: HeaderMap, via_addr: bool) -> Option<HeaderValue> {
480+
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
481+
482+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
483+
let addr = listener.local_addr().unwrap().to_string();
484+
let server = tokio::spawn(async move {
485+
let (stream, _) = listener.accept().await.unwrap();
486+
let mut received = None;
487+
let _ws = tokio_tungstenite::accept_hdr_async(
488+
stream,
489+
|request: &Request, response: Response| {
490+
received = request.headers().get(USER_AGENT).cloned();
491+
Ok(response)
492+
},
493+
)
494+
.await
495+
.unwrap();
496+
received
497+
});
498+
let url = format!("ws://{addr}");
499+
let _conn = if via_addr {
500+
NostrWsConnection::connect_to_addr(&url, &addr).await
501+
} else {
502+
NostrWsConnection::connect_with_headers(&url, headers).await
503+
}
504+
.unwrap();
505+
server.await.unwrap()
506+
}
507+
508+
#[tokio::test]
509+
async fn upgrade_sends_default_user_agent() {
510+
let expected = Some(HeaderValue::from_static(DEFAULT_USER_AGENT));
511+
assert_eq!(upgrade_user_agent(HeaderMap::new(), false).await, expected);
512+
assert_eq!(upgrade_user_agent(HeaderMap::new(), true).await, expected);
513+
assert!(DEFAULT_USER_AGENT.starts_with("buzz-ws-client/"));
514+
}
515+
516+
#[tokio::test]
517+
async fn caller_user_agent_replaces_default() {
518+
let mut headers = HeaderMap::new();
519+
headers.insert(USER_AGENT, HeaderValue::from_static("example/1.0"));
520+
assert_eq!(
521+
upgrade_user_agent(headers, false).await,
522+
Some(HeaderValue::from_static("example/1.0"))
523+
);
524+
}
525+
463526
/// A relay that answers NIP-42 AUTH by closing with `code`, as the relay
464527
/// does on a NIP-FI key mismatch (1008).
465528
async fn close_after_auth(code: u16) -> WsClientError {

0 commit comments

Comments
 (0)