Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

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

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,14 @@ moka = { version = "0.12", features = ["sync"] }
# Async stream utilities
futures-util = "0.3"

# WebSocket client (test client)
# WebSocket clients
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
hyper-util = { version = "0.1", features = [
"client-legacy",
"client-proxy",
"client-proxy-system",
] }
tower-service = "0.3"
url = "2"

# Property-based testing (dev-only)
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ path = "src/main.rs"
# Internal
buzz-core = { workspace = true }
buzz-sdk = { workspace = true }
buzz-ws-client = { workspace = true }
buzz-persona = { path = "../buzz-persona" }

# Nostr
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ All configuration is via environment variables (or CLI flags — every env var h
| `BUZZ_ACP_MAX_TURN_DURATION` | no | `7200` | Absolute wall-clock cap per turn (safety valve). |
| `BUZZ_API_TOKEN` | no | — | API token (required if relay enforces token auth). |

Relay WebSocket connections honor `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`,
and `NO_PROXY`. Static macOS and Windows system proxy settings are used when
the environment does not override them. HTTP CONNECT and SOCKS proxy URLs are
supported; loopback relay URLs always connect directly.

**Note:** `BUZZ_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`.

**Legacy env vars:** `BUZZ_ACP_PRIVATE_KEY`, `BUZZ_ACP_API_TOKEN`, and `BUZZ_ACP_TURN_TIMEOUT` (replaced by `BUZZ_ACP_IDLE_TIMEOUT`) are still accepted as fallbacks.
Expand Down
60 changes: 43 additions & 17 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,13 @@ use buzz_core::kind::{
KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_TYPING_INDICATOR,
};
use buzz_ws_client::connect_websocket;
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag};
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tracing::{debug, info, warn};
use uuid::Uuid;

Expand Down Expand Up @@ -436,7 +437,7 @@ pub struct BuzzEvent {
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
#[error("WebSocket error: {0}")]
WebSocket(Box<tokio_tungstenite::tungstenite::Error>),
WebSocket(#[source] Box<tokio_tungstenite::tungstenite::Error>),

#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
Expand Down Expand Up @@ -3346,17 +3347,27 @@ fn jittered_duration(base: Duration) -> Duration {

/// Classify a `RelayError` as a DNS resolution failure.
///
/// Matches the OS-level "name not found" strings surfaced by the platform's
/// resolver, covering macOS (`nodename nor servname`), Linux (`Name or service not
/// known`), and common BSD/Windows variants (`No such host`,
/// `failed to lookup address`). These are transient on brownouts and must NOT
/// consume a backoff ladder rung — they retry on a flat `DNS_RETRY_INTERVAL`.
/// Walks the error source chain so connector context does not hide the
/// resolver failure. Matches hyper-util's typed DNS error plus the OS-level
/// strings surfaced on macOS, Linux, BSD, and Windows. These are transient on
/// brownouts and must NOT consume a backoff ladder rung — they retry on a flat
/// `DNS_RETRY_INTERVAL`.
pub(crate) fn is_dns_error(err: &RelayError) -> bool {
let msg = err.to_string();
msg.contains("nodename nor servname")
|| msg.contains("Name or service not known")
|| msg.contains("No such host")
|| msg.contains("failed to lookup address")
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(error) = current {
let message = error.to_string().to_ascii_lowercase();
if message == "dns error"
|| message.contains("nodename nor servname")
|| message.contains("name or service not known")
|| message.contains("no such host")
|| message.contains("failed to lookup address")
{
return true;
}
current = error.source();
}

false
}

/// Shutdown-aware fixed-duration sleep for REQ pacing in `resubscribe_after_reconnect`.
Expand Down Expand Up @@ -3652,7 +3663,7 @@ pub(crate) fn parse_relay_message(text: &str) -> Result<RelayMessage, RelayError
/// EOF, timeout, refused) and ambiguous TLS errors stay retryable.
/// - `WebSocket(ConnectionClosed)` — link-level closure.
/// - `WebSocket(AlreadyClosed)`, `WebSocket(WriteBufferFull)` — unreachable
/// during `connect_async`; kept fail-safe transient.
/// during connection establishment; kept fail-safe transient.
/// - `NoAuthChallenge`, `ConnectionClosed`, `Timeout` — timing/link noise.
fn is_terminal_connect_error(err: &RelayError) -> bool {
match err {
Expand Down Expand Up @@ -3708,7 +3719,7 @@ fn is_terminal_ws_error(err: &tokio_tungstenite::tungstenite::Error) -> bool {
// downcast above).
WsError::Tls(_) => true,

// Unreachable during connect_async; kept fail-safe transient.
// Unreachable during connection establishment; kept fail-safe transient.
WsError::AlreadyClosed | WsError::WriteBufferFull(_) => false,
}
}
Expand Down Expand Up @@ -3831,7 +3842,7 @@ async fn do_connect(
.parse::<url::Url>()
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;

let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_websocket(parsed.as_str()))
.await
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
Expand Down Expand Up @@ -4348,7 +4359,7 @@ mod tests {
.await
.expect("complete server websocket handshake")
});
let (client, _) = connect_async(format!("ws://{address}"))
let (client, _) = tokio_tungstenite::connect_async(format!("ws://{address}"))
.await
.expect("connect test websocket");
(client, server.await.expect("join test websocket server"))
Expand Down Expand Up @@ -6013,14 +6024,29 @@ mod tests {
"failed to lookup address information".into()
)));
// F15: production-shaped error — RelayError::WebSocket wrapping a
// tungstenite I/O error (the shape emitted by connect_async on macOS).
// tungstenite I/O error (the shape emitted by connection failures on macOS).
let ws_io_err = RelayError::WebSocket(Box::new(tungstenite::Error::Io(
std::io::Error::other("nodename nor servname provided, or not known"),
)));
assert!(
is_dns_error(&ws_io_err),
"WebSocket-wrapped I/O DNS error must be classified as DNS"
);
#[derive(Debug, thiserror::Error)]
#[error("connection failed")]
struct NestedConnectionError {
#[source]
source: std::io::Error,
}
let nested_dns_err = RelayError::WebSocket(Box::new(tungstenite::Error::Io(
std::io::Error::other(NestedConnectionError {
source: std::io::Error::other("Name or service not known"),
}),
)));
assert!(
is_dns_error(&nested_dns_err),
"connector context must not hide a nested resolver failure"
);
// Normal connection errors are NOT DNS errors.
assert!(!is_dns_error(&RelayError::Timeout));
assert!(!is_dns_error(&RelayError::ConnectionClosed));
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-pairing-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ path = "src/main.rs"

[dependencies]
buzz-core = { workspace = true }
buzz-ws-client = { workspace = true }
nostr = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
Expand Down
7 changes: 4 additions & 3 deletions crates/buzz-pairing-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ use buzz_core::pairing::{
types::PayloadType,
PairingError,
};
use buzz_ws_client::connect_websocket;
use clap::{Parser, Subcommand};
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, RelayUrl, SecretKey, ToBech32};
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tokio_tungstenite::tungstenite::Message;
use zeroize::Zeroizing;

#[derive(Parser)]
Expand Down Expand Up @@ -125,7 +126,7 @@ async fn cmd_source(relay_url: String, nsec: Option<String>) -> Result<(), CliEr

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

Expand Down Expand Up @@ -227,7 +228,7 @@ async fn cmd_target(relay_override: Option<String>, show_secret: bool) -> Result
let (mut session, offer_event) = PairingSession::new_target(&qr)?;

// Connect to relay and handle NIP-42 auth if required.
let (ws, _) = connect_async(&relay_url).await?;
let (ws, _) = connect_websocket(&relay_url).await?;
let (mut write, mut read) = ws.split();
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;

Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-ws-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ repository.workspace = true
nostr = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
hyper-util = { workspace = true }
tower-service = { workspace = true }
futures-util = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
tracing = { workspace = true }

[target.'cfg(windows)'.dependencies]
windows-registry = "0.6"
5 changes: 3 additions & 2 deletions crates/buzz-ws-client/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ use futures_util::{SinkExt, StreamExt};
use nostr::{Event, Keys, Tag};
use serde_json::{json, Value};
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tracing::debug;

use crate::error::WsClientError;
use crate::message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage};
use crate::proxy::connect_websocket;

type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;

Expand Down Expand Up @@ -50,7 +51,7 @@ impl NostrWsConnection {
.parse::<url::Url>()
.map_err(|e| WsClientError::Url(e.to_string()))?;

let (ws, _response) = connect_async(parsed.as_str())
let (ws, _response) = connect_websocket(parsed.as_str())
.await
.map_err(WsClientError::WebSocket)?;

Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-ws-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
pub mod connection;
pub mod error;
pub mod message;
pub mod proxy;

pub use connection::{publish_event, NostrWsConnection};
pub use error::WsClientError;
pub use message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage};
pub use proxy::connect_websocket;
Loading