mirror of
https://github.com/block/buzz.git
synced 2026-11-11 11:51:13 +00:00
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 <wpfleger@block.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
This commit is contained in:
2
Justfile
2
Justfile
@@ -423,6 +423,8 @@ test-unit:
|
|||||||
# never dialed). Keep the Cargo fallback in step.
|
# never dialed). Keep the Cargo fallback in step.
|
||||||
cargo nextest run -p buzz-relay-mesh
|
cargo nextest run -p buzz-relay-mesh
|
||||||
cargo nextest run -p buzz-cli
|
cargo nextest run -p buzz-cli
|
||||||
|
cargo nextest run -p buzz-ws-client
|
||||||
|
cargo nextest run -p buzz-pairing-cli
|
||||||
# buzz-sdk builder/validation unit tests: pure event-builder and input
|
# buzz-sdk builder/validation unit tests: pure event-builder and input
|
||||||
# validation (e.g. the canvas writer-discipline/skew guard and the
|
# validation (e.g. the canvas writer-discipline/skew guard and the
|
||||||
# canvas_write_survived predicate), no infra. `--lib` runs all unit
|
# canvas_write_survived predicate), no infra. `--lib` runs all unit
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderValue};
|
||||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -4204,6 +4206,9 @@ where
|
|||||||
Err(last_err.unwrap_or(RelayError::ConnectionClosed))
|
Err(last_err.unwrap_or(RelayError::ConnectionClosed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `User-Agent` sent on every relay WebSocket upgrade.
|
||||||
|
const RELAY_USER_AGENT: &str = concat!("buzz-acp/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
/// Perform a single WebSocket connect + NIP-42 auth handshake.
|
/// Perform a single WebSocket connect + NIP-42 auth handshake.
|
||||||
///
|
///
|
||||||
/// Returns `(ws, buffer)` on success.
|
/// Returns `(ws, buffer)` on success.
|
||||||
@@ -4216,7 +4221,14 @@ async fn do_connect(
|
|||||||
.parse::<url::Url>()
|
.parse::<url::Url>()
|
||||||
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
|
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
|
||||||
|
|
||||||
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
|
let mut request = parsed
|
||||||
|
.as_str()
|
||||||
|
.into_client_request()
|
||||||
|
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT));
|
||||||
|
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
|
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
|
||||||
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
|
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
|
||||||
@@ -6375,6 +6387,34 @@ mod tests {
|
|||||||
/// WebSocket endpoint, and tungstenite returns `Error::Http` (non-101
|
/// WebSocket endpoint, and tungstenite returns `Error::Http` (non-101
|
||||||
/// response) or `Error::Url(UnsupportedUrlScheme)` depending on how far
|
/// response) or `Error::Url(UnsupportedUrlScheme)` depending on how far
|
||||||
/// the handshake gets. Either way it must not be retried.
|
/// the handshake gets. Either way it must not be retried.
|
||||||
|
/// The upgrade `do_connect()` sends names buzz-acp and its version.
|
||||||
|
#[allow(clippy::result_large_err)] // tungstenite's handshake callback type
|
||||||
|
#[tokio::test]
|
||||||
|
async fn do_connect_sends_user_agent() {
|
||||||
|
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let url = format!("ws://{}", listener.local_addr().unwrap());
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut received = None;
|
||||||
|
let _ws = tokio_tungstenite::accept_hdr_async(
|
||||||
|
stream,
|
||||||
|
|request: &Request, response: Response| {
|
||||||
|
received = request.headers().get(USER_AGENT).cloned();
|
||||||
|
Ok(response)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
received
|
||||||
|
});
|
||||||
|
// The server hangs up after the upgrade, so the AUTH handshake fails.
|
||||||
|
let _ = do_connect(&url, &nostr::Keys::generate(), None).await;
|
||||||
|
assert_eq!(server.await.unwrap().unwrap(), RELAY_USER_AGENT);
|
||||||
|
assert!(RELAY_USER_AGENT.starts_with("buzz-acp/"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn do_connect_wrong_scheme_is_terminal() {
|
async fn do_connect_wrong_scheme_is_terminal() {
|
||||||
let keys = nostr::Keys::generate();
|
let keys = nostr::Keys::generate();
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ use serde_json::{json, Value};
|
|||||||
use std::{collections::BTreeSet, future::Future, time::Duration};
|
use std::{collections::BTreeSet, future::Future, time::Duration};
|
||||||
use tokio_tungstenite::{
|
use tokio_tungstenite::{
|
||||||
connect_async_tls_with_config,
|
connect_async_tls_with_config,
|
||||||
tungstenite::{client::IntoClientRequest, protocol::WebSocketConfig, Message},
|
tungstenite::{
|
||||||
|
client::IntoClientRequest,
|
||||||
|
http::{header::USER_AGENT, HeaderValue},
|
||||||
|
protocol::WebSocketConfig,
|
||||||
|
Message,
|
||||||
|
},
|
||||||
Connector, MaybeTlsStream, WebSocketStream,
|
Connector, MaybeTlsStream, WebSocketStream,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,11 +101,15 @@ async fn control(socket: &mut AudioSocket) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `User-Agent` sent on the upgrade unless the request already carries one.
|
||||||
|
const DEFAULT_USER_AGENT: &str = concat!("buzz-audio-client/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
/// Connect and authenticate using a host-provided asynchronous signer.
|
/// Connect and authenticate using a host-provided asynchronous signer.
|
||||||
///
|
///
|
||||||
/// The host validates the destination before calling, and constructs the NIP-42
|
/// The host validates the destination before calling, and constructs the NIP-42
|
||||||
/// event (including its exact relay tag) from the challenge. `request` is the
|
/// event (including its exact relay tag) from the challenge. `request` is the
|
||||||
/// audio URL, or a request carrying host-supplied upgrade headers. No private key is
|
/// audio URL, or a request carrying host-supplied upgrade headers; without a
|
||||||
|
/// `User-Agent` it sends `buzz-audio-client/<version>`. No private key is
|
||||||
/// accepted. Dropping this future cancels connection/admission; the entire
|
/// accepted. Dropping this future cancels connection/admission; the entire
|
||||||
/// operation is bounded to 15 seconds, including signing.
|
/// operation is bounded to 15 seconds, including signing.
|
||||||
pub async fn connect<F, Fut>(
|
pub async fn connect<F, Fut>(
|
||||||
@@ -117,6 +126,13 @@ where
|
|||||||
let config = WebSocketConfig::default()
|
let config = WebSocketConfig::default()
|
||||||
.max_message_size(Some(65_536))
|
.max_message_size(Some(65_536))
|
||||||
.max_frame_size(Some(65_536));
|
.max_frame_size(Some(65_536));
|
||||||
|
let mut request = request
|
||||||
|
.into_client_request()
|
||||||
|
.map_err(|e| format!("audio WS connect failed: {e}"))?;
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.entry(USER_AGENT)
|
||||||
|
.or_insert(HeaderValue::from_static(DEFAULT_USER_AGENT));
|
||||||
let (mut socket, _) =
|
let (mut socket, _) =
|
||||||
connect_async_tls_with_config(request, Some(config), false, connector)
|
connect_async_tls_with_config(request, Some(config), false, connector)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -149,7 +149,11 @@ async fn request_headers_reach_the_upgrade() {
|
|||||||
let (stream, _) = listener.accept().await.unwrap();
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
let (seen, header) = oneshot::channel();
|
let (seen, header) = oneshot::channel();
|
||||||
let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| {
|
let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| {
|
||||||
let _ = seen.send(request.headers().get("authorization").cloned());
|
let headers = request.headers();
|
||||||
|
let _ = seen.send((
|
||||||
|
headers.get("authorization").cloned(),
|
||||||
|
headers.get("user-agent").cloned(),
|
||||||
|
));
|
||||||
Ok(response)
|
Ok(response)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -182,8 +186,11 @@ async fn request_headers_reach_the_upgrade() {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
let (authorization, user_agent) = server.await.unwrap();
|
||||||
server.await.unwrap().unwrap().to_str().unwrap(),
|
assert_eq!(authorization.unwrap(), "Bearer upgrade-token");
|
||||||
"Bearer upgrade-token"
|
let user_agent = user_agent.unwrap();
|
||||||
);
|
assert!(user_agent
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("buzz-audio-client/"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use crate::error::CliError;
|
|||||||
|
|
||||||
/// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport).
|
/// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport).
|
||||||
const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity";
|
const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity";
|
||||||
|
/// `User-Agent` sent on every WebSocket upgrade.
|
||||||
|
const USER_AGENT: &str = concat!("buzz-cli/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
/// Descriptor returned by the relay after a successful upload.
|
/// Descriptor returned by the relay after a successful upload.
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -704,10 +706,14 @@ impl BuzzClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The NIP-FI assertion as WebSocket upgrade headers (empty without an
|
/// The WebSocket upgrade headers: the CLI's `User-Agent`, and the NIP-FI
|
||||||
/// evidence source).
|
/// assertion when there is an evidence source.
|
||||||
async fn upgrade_headers(&self) -> Result<buzz_ws_client::HeaderMap, CliError> {
|
async fn upgrade_headers(&self) -> Result<buzz_ws_client::HeaderMap, CliError> {
|
||||||
let mut headers = buzz_ws_client::HeaderMap::new();
|
let mut headers = buzz_ws_client::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"user-agent",
|
||||||
|
buzz_ws_client::HeaderValue::from_static(USER_AGENT),
|
||||||
|
);
|
||||||
if let Some(assertion) = self.evidence().await? {
|
if let Some(assertion) = self.evidence().await? {
|
||||||
let mut value =
|
let mut value =
|
||||||
buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}"))
|
buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}"))
|
||||||
@@ -4095,6 +4101,20 @@ mod ws_deadline_tests {
|
|||||||
|
|
||||||
pub(super) type Log = Arc<std::sync::Mutex<Vec<String>>>;
|
pub(super) type Log = Arc<std::sync::Mutex<Vec<String>>>;
|
||||||
|
|
||||||
|
/// Every upgrade names the CLI and its version.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upgrade_headers_carry_the_cli_user_agent() {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let c = BuzzClient::new(
|
||||||
|
"http://127.0.0.1:1".into(),
|
||||||
|
Credentials::from_signer(Arc::new(keys.clone()), keys.public_key()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let headers = c.upgrade_headers().await.unwrap();
|
||||||
|
assert_eq!(headers.get("user-agent").unwrap(), super::USER_AGENT);
|
||||||
|
assert!(super::USER_AGENT.starts_with("buzz-cli/"));
|
||||||
|
}
|
||||||
|
|
||||||
/// A sign-in signature that comes back after the relay's 5 s deadline
|
/// A sign-in signature that comes back after the relay's 5 s deadline
|
||||||
/// reconnects and signs the new challenge, never the old one.
|
/// reconnects and signs the new challenge, never the old one.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ use clap::{Parser, Subcommand};
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use nostr::{Event, EventBuilder, Keys, RelayUrl, SecretKey, ToBech32};
|
use nostr::{Event, EventBuilder, Keys, RelayUrl, SecretKey, ToBech32};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
use tokio_tungstenite::tungstenite::{
|
||||||
|
client::IntoClientRequest,
|
||||||
|
handshake::client::Request,
|
||||||
|
http::{header::USER_AGENT, HeaderValue},
|
||||||
|
};
|
||||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ async fn cmd_source(relay_url: String, nsec: Option<String>) -> Result<(), CliEr
|
|||||||
|
|
||||||
// Connect to relay and handle NIP-42 auth if required.
|
// Connect to relay and handle NIP-42 auth if required.
|
||||||
// Auth uses the session's ephemeral keys so the relay accepts our events.
|
// Auth uses the session's ephemeral keys so the relay accepts our events.
|
||||||
let (ws, _) = connect_async(&relay_url).await?;
|
let (ws, _) = connect_async(relay_request(&relay_url)?).await?;
|
||||||
let (mut write, mut read) = ws.split();
|
let (mut write, mut read) = ws.split();
|
||||||
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
|
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
|
||||||
|
|
||||||
@@ -235,7 +240,7 @@ async fn cmd_target(relay_override: Option<String>, show_secret: bool) -> Result
|
|||||||
let (mut session, offer_event) = PairingSession::new_target(&qr)?;
|
let (mut session, offer_event) = PairingSession::new_target(&qr)?;
|
||||||
|
|
||||||
// Connect to relay and handle NIP-42 auth if required.
|
// Connect to relay and handle NIP-42 auth if required.
|
||||||
let (ws, _) = connect_async(&relay_url).await?;
|
let (ws, _) = connect_async(relay_request(&relay_url)?).await?;
|
||||||
let (mut write, mut read) = ws.split();
|
let (mut write, mut read) = ws.split();
|
||||||
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
|
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
|
||||||
|
|
||||||
@@ -629,3 +634,27 @@ fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> {
|
|||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'")))
|
.map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `User-Agent` sent on the relay WebSocket upgrade.
|
||||||
|
const RELAY_USER_AGENT: &str = concat!("buzz-pairing-cli/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
/// The relay upgrade request for `url`, naming this tool and its version.
|
||||||
|
fn relay_request(url: &str) -> Result<Request, tokio_tungstenite::tungstenite::Error> {
|
||||||
|
let mut request = url.into_client_request()?;
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT));
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relay_request_carries_user_agent() {
|
||||||
|
let request = relay_request("wss://relay.example.com").unwrap();
|
||||||
|
assert_eq!(request.headers()[USER_AGENT], RELAY_USER_AGENT);
|
||||||
|
assert!(RELAY_USER_AGENT.starts_with("buzz-pairing-cli/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use nostr::{Event, NostrSigner, Tag};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::http::HeaderMap;
|
use tokio_tungstenite::tungstenite::handshake::client::Request;
|
||||||
|
use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderMap, HeaderValue};
|
||||||
use tokio_tungstenite::{
|
use tokio_tungstenite::{
|
||||||
client_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream,
|
client_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream,
|
||||||
};
|
};
|
||||||
@@ -40,6 +41,19 @@ async fn phase<T>(
|
|||||||
.map_err(|_| WsClientError::Timeout)?
|
.map_err(|_| WsClientError::Timeout)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `User-Agent` sent on the upgrade unless the caller's headers supply one.
|
||||||
|
const DEFAULT_USER_AGENT: &str = concat!("buzz-ws-client/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
/// The upgrade request for `url`: the default `User-Agent`, then `headers`.
|
||||||
|
fn upgrade_request(url: &url::Url, headers: HeaderMap) -> Result<Request, WsClientError> {
|
||||||
|
let mut request = url.as_str().into_client_request()?;
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT));
|
||||||
|
request.headers_mut().extend(headers);
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
/// A NIP-42-capable WebSocket connection to a Nostr relay.
|
/// A NIP-42-capable WebSocket connection to a Nostr relay.
|
||||||
pub struct NostrWsConnection {
|
pub struct NostrWsConnection {
|
||||||
ws: WsStream,
|
ws: WsStream,
|
||||||
@@ -85,7 +99,8 @@ impl NostrWsConnection {
|
|||||||
/// [`Self::connect`], sending `headers` on the WebSocket upgrade request.
|
/// [`Self::connect`], sending `headers` on the WebSocket upgrade request.
|
||||||
///
|
///
|
||||||
/// The headers are added after the handshake headers, so supplying a handshake
|
/// The headers are added after the handshake headers, so supplying a handshake
|
||||||
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade.
|
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade. A
|
||||||
|
/// `User-Agent` among them replaces the default `buzz-ws-client/<version>`.
|
||||||
pub async fn connect_with_headers(
|
pub async fn connect_with_headers(
|
||||||
url: &str,
|
url: &str,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -94,8 +109,7 @@ impl NostrWsConnection {
|
|||||||
.parse::<url::Url>()
|
.parse::<url::Url>()
|
||||||
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
||||||
|
|
||||||
let mut request = parsed.as_str().into_client_request()?;
|
let request = upgrade_request(&parsed, headers)?;
|
||||||
request.headers_mut().extend(headers);
|
|
||||||
|
|
||||||
let (ws, _response) = connect_async(request)
|
let (ws, _response) = connect_async(request)
|
||||||
.await
|
.await
|
||||||
@@ -117,7 +131,7 @@ impl NostrWsConnection {
|
|||||||
let parsed = url
|
let parsed = url
|
||||||
.parse::<url::Url>()
|
.parse::<url::Url>()
|
||||||
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
||||||
let request = parsed.as_str().into_client_request()?;
|
let request = upgrade_request(&parsed, HeaderMap::new())?;
|
||||||
let tcp = tokio::net::TcpStream::connect(addr)
|
let tcp = tokio::net::TcpStream::connect(addr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WsClientError::WebSocket(e.into()))?;
|
.map_err(|e| WsClientError::WebSocket(e.into()))?;
|
||||||
@@ -426,7 +440,6 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connect_with_headers_sends_headers_on_upgrade() {
|
async fn connect_with_headers_sends_headers_on_upgrade() {
|
||||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let url = format!("ws://{}", listener.local_addr().unwrap());
|
let url = format!("ws://{}", listener.local_addr().unwrap());
|
||||||
@@ -460,6 +473,56 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `User-Agent` a relay sees on an upgrade sent with `headers`, through
|
||||||
|
/// `connect_with_headers` or, with `via_addr`, `connect_to_addr`.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
async fn upgrade_user_agent(headers: HeaderMap, via_addr: bool) -> Option<HeaderValue> {
|
||||||
|
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut received = None;
|
||||||
|
let _ws = tokio_tungstenite::accept_hdr_async(
|
||||||
|
stream,
|
||||||
|
|request: &Request, response: Response| {
|
||||||
|
received = request.headers().get(USER_AGENT).cloned();
|
||||||
|
Ok(response)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
received
|
||||||
|
});
|
||||||
|
let url = format!("ws://{addr}");
|
||||||
|
let _conn = if via_addr {
|
||||||
|
NostrWsConnection::connect_to_addr(&url, &addr).await
|
||||||
|
} else {
|
||||||
|
NostrWsConnection::connect_with_headers(&url, headers).await
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
server.await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upgrade_sends_default_user_agent() {
|
||||||
|
let expected = Some(HeaderValue::from_static(DEFAULT_USER_AGENT));
|
||||||
|
assert_eq!(upgrade_user_agent(HeaderMap::new(), false).await, expected);
|
||||||
|
assert_eq!(upgrade_user_agent(HeaderMap::new(), true).await, expected);
|
||||||
|
assert!(DEFAULT_USER_AGENT.starts_with("buzz-ws-client/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn caller_user_agent_replaces_default() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(USER_AGENT, HeaderValue::from_static("example/1.0"));
|
||||||
|
assert_eq!(
|
||||||
|
upgrade_user_agent(headers, false).await,
|
||||||
|
Some(HeaderValue::from_static("example/1.0"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A relay that answers NIP-42 AUTH by closing with `code`, as the relay
|
/// A relay that answers NIP-42 AUTH by closing with `code`, as the relay
|
||||||
/// does on a NIP-FI key mismatch (1008).
|
/// does on a NIP-FI key mismatch (1008).
|
||||||
async fn close_after_auth(code: u16) -> WsClientError {
|
async fn close_after_auth(code: u16) -> WsClientError {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
@@ -11,6 +12,7 @@ import 'features/invites/invite_join_provider.dart';
|
|||||||
import 'shared/agents/agents_surface.dart';
|
import 'shared/agents/agents_surface.dart';
|
||||||
import 'shared/push/push_bridge.dart';
|
import 'shared/push/push_bridge.dart';
|
||||||
import 'shared/relay/relay_closed_policy.dart';
|
import 'shared/relay/relay_closed_policy.dart';
|
||||||
|
import 'shared/relay/relay_websocket.dart';
|
||||||
import 'shared/theme/theme_provider.dart';
|
import 'shared/theme/theme_provider.dart';
|
||||||
|
|
||||||
void main() => runBuzzApp(const App());
|
void main() => runBuzzApp(const App());
|
||||||
@@ -18,9 +20,14 @@ void main() => runBuzzApp(const App());
|
|||||||
Future<void> runBuzzApp(Widget app) async {
|
Future<void> runBuzzApp(Widget app) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
installBuzzPushMethodHandler();
|
installBuzzPushMethodHandler();
|
||||||
|
// Relay upgrades name the app version, so learn it before any socket opens.
|
||||||
|
final userAgent = loadRelayUserAgent(
|
||||||
|
() async => (await PackageInfo.fromPlatform()).version,
|
||||||
|
);
|
||||||
|
|
||||||
// Pre-load preferences so the first frame uses the saved theme/accent.
|
// Pre-load preferences so the first frame uses the saved theme/accent.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await userAgent;
|
||||||
|
|
||||||
runApp(buildRootProviderScope(prefs: prefs, child: app));
|
runApp(buildRootProviderScope(prefs: prefs, child: app));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,26 @@ import 'dart:io';
|
|||||||
/// leaves the app reconnecting for as long as the network stays stalled.
|
/// leaves the app reconnecting for as long as the network stays stalled.
|
||||||
const relayWebSocketConnectTimeout = Duration(seconds: 10);
|
const relayWebSocketConnectTimeout = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// `User-Agent` sent on every relay WebSocket upgrade; [setRelayUserAgent]
|
||||||
|
/// adds the app version once it is known.
|
||||||
|
String relayUserAgent = 'Buzz-Mobile';
|
||||||
|
|
||||||
|
void setRelayUserAgent(String version) =>
|
||||||
|
relayUserAgent = 'Buzz-Mobile/$version';
|
||||||
|
|
||||||
|
/// Sets [relayUserAgent] from [lookupVersion] before any socket opens, giving
|
||||||
|
/// up after [timeout] so a hung or failed lookup cannot stall launch.
|
||||||
|
Future<void> loadRelayUserAgent(
|
||||||
|
Future<String> Function() lookupVersion, {
|
||||||
|
Duration timeout = const Duration(milliseconds: 500),
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
setRelayUserAgent(await lookupVersion().timeout(timeout));
|
||||||
|
} catch (_) {
|
||||||
|
// Keep the unversioned fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// WebSocket's default HTTP client follows redirects, including custom headers.
|
/// WebSocket's default HTTP client follows redirects, including custom headers.
|
||||||
/// Keep key-bound assertions at their original origin and bound upgrade time.
|
/// Keep key-bound assertions at their original origin and bound upgrade time.
|
||||||
///
|
///
|
||||||
@@ -20,6 +40,7 @@ Future<WebSocket> openRelayWebSocket(
|
|||||||
Duration connectTimeout = relayWebSocketConnectTimeout,
|
Duration connectTimeout = relayWebSocketConnectTimeout,
|
||||||
}) async {
|
}) async {
|
||||||
final HttpClient client = headers.isEmpty ? HttpClient() : _UpgradeClient();
|
final HttpClient client = headers.isEmpty ? HttpClient() : _UpgradeClient();
|
||||||
|
client.userAgent = relayUserAgent;
|
||||||
try {
|
try {
|
||||||
return await WebSocket.connect(
|
return await WebSocket.connect(
|
||||||
uri.toString(),
|
uri.toString(),
|
||||||
@@ -51,6 +72,12 @@ class _UpgradeClient implements HttpClient {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get userAgent => _inner.userAgent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set userAgent(String? value) => _inner.userAgent = value;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void close({bool force = false}) => _inner.close(force: force);
|
void close({bool force = false}) => _inner.close(force: force);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'package:buzz/shared/push/push_bootstrap.dart';
|
|||||||
import 'package:buzz/shared/push/push_bridge.dart';
|
import 'package:buzz/shared/push/push_bridge.dart';
|
||||||
import 'package:buzz/shared/push/push_subscription.dart';
|
import 'package:buzz/shared/push/push_subscription.dart';
|
||||||
import 'package:nostr/nostr.dart' as nostr;
|
import 'package:nostr/nostr.dart' as nostr;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:buzz/shared/push/push_lease_revocation_outbox.dart';
|
import 'package:buzz/shared/push/push_lease_revocation_outbox.dart';
|
||||||
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
|
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
|
||||||
import 'package:buzz/shared/relay/relay.dart';
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
@@ -339,6 +340,13 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
PackageInfo.setMockInitialValues(
|
||||||
|
appName: 'Buzz',
|
||||||
|
packageName: 'example.buzz',
|
||||||
|
version: '1.0.0',
|
||||||
|
buildNumber: '1',
|
||||||
|
buildSignature: '',
|
||||||
|
);
|
||||||
const channel = MethodChannel('buzz/push');
|
const channel = MethodChannel('buzz/push');
|
||||||
final messenger =
|
final messenger =
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
|||||||
@@ -305,4 +305,61 @@ void main() {
|
|||||||
await sink.close(force: true);
|
await sink.close(force: true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('upgrades name the app and its version', () async {
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final agents = <String?>[];
|
||||||
|
server.listen((request) async {
|
||||||
|
agents.add(request.headers.value(HttpHeaders.userAgentHeader));
|
||||||
|
final ws = await WebSocketTransformer.upgrade(request);
|
||||||
|
ws.listen((_) {}, onError: (_) {}, onDone: () {});
|
||||||
|
});
|
||||||
|
setRelayUserAgent('1.2.3');
|
||||||
|
for (final headers in [
|
||||||
|
<String, String>{},
|
||||||
|
{'Nostr-Federated-Identity': 'Bearer synthetic'},
|
||||||
|
]) {
|
||||||
|
final socket = await openRelayWebSocket(
|
||||||
|
Uri.parse('ws://127.0.0.1:${server.port}'),
|
||||||
|
headers: headers,
|
||||||
|
pingInterval: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
await socket.close();
|
||||||
|
}
|
||||||
|
expect(agents, ['Buzz-Mobile/1.2.3', 'Buzz-Mobile/1.2.3']);
|
||||||
|
await server.close(force: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first upgrade waits for a delayed version lookup', () async {
|
||||||
|
final agents = <String?>[];
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
server.listen((request) async {
|
||||||
|
agents.add(request.headers.value(HttpHeaders.userAgentHeader));
|
||||||
|
final ws = await WebSocketTransformer.upgrade(request);
|
||||||
|
ws.listen((_) {}, onError: (_) {}, onDone: () {});
|
||||||
|
});
|
||||||
|
relayUserAgent = 'Buzz-Mobile';
|
||||||
|
await loadRelayUserAgent(
|
||||||
|
() => Future.delayed(const Duration(milliseconds: 100), () => '4.5.6'),
|
||||||
|
);
|
||||||
|
final socket = await openRelayWebSocket(
|
||||||
|
Uri.parse('ws://127.0.0.1:${server.port}'),
|
||||||
|
headers: const {},
|
||||||
|
pingInterval: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
await socket.close();
|
||||||
|
expect(agents, ['Buzz-Mobile/4.5.6']);
|
||||||
|
await server.close(force: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hung or failed version lookup keeps the unversioned agent', () async {
|
||||||
|
relayUserAgent = 'Buzz-Mobile';
|
||||||
|
await loadRelayUserAgent(
|
||||||
|
() => Completer<String>().future,
|
||||||
|
timeout: const Duration(milliseconds: 10),
|
||||||
|
);
|
||||||
|
expect(relayUserAgent, 'Buzz-Mobile');
|
||||||
|
await loadRelayUserAgent(() async => throw StateError('no platform'));
|
||||||
|
expect(relayUserAgent, 'Buzz-Mobile');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ run_unit_tests() {
|
|||||||
run_test_step "buzz-cli tests" \
|
run_test_step "buzz-cli tests" \
|
||||||
cargo test -p buzz-cli -- --nocapture
|
cargo test -p buzz-cli -- --nocapture
|
||||||
|
|
||||||
|
run_test_step "buzz-ws-client tests" \
|
||||||
|
cargo test -p buzz-ws-client -- --nocapture
|
||||||
|
|
||||||
|
run_test_step "buzz-pairing-cli tests" \
|
||||||
|
cargo test -p buzz-pairing-cli -- --nocapture
|
||||||
|
|
||||||
# buzz-sdk builder/validation unit tests: pure event-builder and input
|
# buzz-sdk builder/validation unit tests: pure event-builder and input
|
||||||
# validation, no infra. Mirrors the nextest path in `just test-unit` — the
|
# validation, no infra. Mirrors the nextest path in `just test-unit` — the
|
||||||
# two lists must stay in step. `--lib` matches the nextest invocation and
|
# two lists must stay in step. `--lib` matches the nextest invocation and
|
||||||
|
|||||||
Reference in New Issue
Block a user