Skip to content
Merged
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
2 changes: 1 addition & 1 deletion crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Core configuration is available through environment variables and matching CLI f
|----------|----------|---------|-------------|
| `BUZZ_PRIVATE_KEY` | **yes** | — | Agent's Nostr private key (`nsec1...`). Used for relay auth and agent identity. |
| `BUZZ_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. |
| `BUZZ_CANONICAL_RELAY_URL` | no | `BUZZ_RELAY_URL` | Deployment-only NIP-42 signing authority when `BUZZ_RELAY_URL` is an edge alias for the same canonical community. |
| `BUZZ_CANONICAL_RELAY_URL` | no | `BUZZ_RELAY_URL` | Deployment-only community authority when `BUZZ_RELAY_URL` is a private transport address or edge alias. It supplies the WebSocket `Host` header and NIP-42/NIP-98 signing authority while the connection still dials `BUZZ_RELAY_URL`. Must be a credential-free `ws://` or `wss://` URL for the same relay. |
| `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. |
| `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). |
| `BUZZ_ACP_KIINGO_PUBLICATION_ENABLED` | no | `false` | Enables the fenced, locally signed Kiingo publication extension. Use only with `kiingo-compute-acp`. |
Expand Down
146 changes: 141 additions & 5 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,15 @@ 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::{
connect_async,
tungstenite::{
client::IntoClientRequest,
http::{header::HOST, HeaderValue, Request},
Message,
},
MaybeTlsStream, WebSocketStream,
};
use tracing::{debug, info, warn};
use uuid::Uuid;

Expand Down Expand Up @@ -3439,6 +3447,73 @@ fn resolve_nip42_relay_url<'a>(dial_url: &'a str, canonical_url: Option<&'a str>
.unwrap_or(dial_url)
}

/// Build the WebSocket upgrade request while keeping transport routing and
/// community authority separate.
///
/// `BUZZ_RELAY_URL` remains the URI used by `connect_async` for DNS and the TCP
/// connection. When a canonical relay URL is configured, only the HTTP `Host`
/// header is replaced with that URL's authority so host-bound relays select the
/// same community they expose at the edge.
fn relay_connect_request(
dial_url: &str,
canonical_url: Option<&str>,
) -> Result<Request<()>, RelayError> {
let parsed_dial = dial_url
.parse::<url::Url>()
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
let mut request = parsed_dial
.as_str()
.into_client_request()
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;

let Some(canonical_url) = canonical_url
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(request);
};

let canonical = canonical_url
.parse::<url::Url>()
.map_err(|e| RelayError::Http(format!("invalid canonical relay URL: {e}")))?;
if !matches!(canonical.scheme(), "ws" | "wss") {
return Err(RelayError::Http(format!(
"invalid canonical relay URL scheme: {}",
canonical.scheme()
)));
}
if !canonical.username().is_empty() || canonical.password().is_some() {
return Err(RelayError::Http(
"canonical relay URL must not contain credentials".into(),
));
}

let host = match canonical.host() {
Some(url::Host::Domain(host)) => host.to_string(),
Some(url::Host::Ipv4(host)) => host.to_string(),
Some(url::Host::Ipv6(host)) => format!("[{host}]"),
None => {
return Err(RelayError::Http(
"canonical relay URL must contain a host".into(),
))
}
};
let default_port = match canonical.scheme() {
"ws" => 80,
"wss" => 443,
_ => unreachable!("canonical scheme validated above"),
};
let authority = match canonical.port() {
Some(port) if port != default_port => format!("{host}:{port}"),
_ => host,
};
let host_header = HeaderValue::from_str(&authority)
.map_err(|e| RelayError::Http(format!("invalid canonical relay authority: {e}")))?;
request.headers_mut().insert(HOST, host_header);

Ok(request)
}

/// Build and send a NIP-42 AUTH response event.
///
/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the
Expand Down Expand Up @@ -3853,11 +3928,10 @@ async fn do_connect(
keys: &Keys,
auth_tag: Option<&nostr::Tag>,
) -> Result<(WsStream, VecDeque<RelayMessage>), RelayError> {
let parsed = relay_url
.parse::<url::Url>()
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
let canonical_url = std::env::var("BUZZ_CANONICAL_RELAY_URL").ok();
let request = relay_connect_request(relay_url, canonical_url.as_deref())?;

let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
.await
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
Expand Down Expand Up @@ -4067,6 +4141,68 @@ mod tests {
);
}

#[test]
fn websocket_request_dials_private_url_with_canonical_host() {
let request =
relay_connect_request("ws://buzz:3000", Some(" wss://chat.kiingo.com/relay/path "))
.expect("request should be valid");

assert_eq!(request.uri(), "ws://buzz:3000/");
assert_eq!(
request
.headers()
.get(HOST)
.and_then(|value| value.to_str().ok()),
Some("chat.kiingo.com")
);
}

#[test]
fn websocket_request_preserves_dial_host_without_canonical_override() {
let request =
relay_connect_request("ws://buzz:3000", None).expect("request should be valid");

assert_eq!(request.uri(), "ws://buzz:3000/");
assert_eq!(
request
.headers()
.get(HOST)
.and_then(|value| value.to_str().ok()),
Some("buzz:3000")
);
}

#[test]
fn websocket_request_keeps_non_default_canonical_port() {
let request = relay_connect_request("ws://buzz:3000", Some("wss://chat.kiingo.com:8443"))
.expect("request should be valid");

assert_eq!(
request
.headers()
.get(HOST)
.and_then(|value| value.to_str().ok()),
Some("chat.kiingo.com:8443")
);
}

#[test]
fn websocket_request_rejects_non_websocket_canonical_url() {
let err = relay_connect_request("ws://buzz:3000", Some("https://chat.kiingo.com"))
.expect_err("non-WebSocket canonical URL should fail closed");

assert!(matches!(err, RelayError::Http(message) if message.contains("scheme")));
}

#[test]
fn websocket_request_rejects_canonical_credentials() {
let err =
relay_connect_request("ws://buzz:3000", Some("wss://user:secret@chat.kiingo.com"))
.expect_err("canonical credentials should fail closed");

assert!(matches!(err, RelayError::Http(message) if message.contains("credentials")));
}

#[test]
fn relay_ws_to_http_plain() {
assert_eq!(
Expand Down
15 changes: 7 additions & 8 deletions deploy/azure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,13 @@ restart therefore returns the same endpoint, agent, and ownership boundary; it
does not create another agent or store a provider credential.

`__BUZZ_RUNTIME_RELAY_URL__` separates the listener's transport endpoint from
the canonical relay URL registered with Kiingo. Render it as
`wss://buzz-preview.kiingo.com` for the restricted pre-cutover deployment, so
the complete relay-to-agent path can be validated while `chat.kiingo.com`
remains detached. After Front Door attaches `chat.kiingo.com` to `buzz-route`,
render it as `wss://chat.kiingo.com` and prove the listener reconnects without
creating another endpoint. The canonical endpoint registration stays
`wss://chat.kiingo.com` in both phases, and the disposable preview hostname is
not a steady-state listener dependency.
the canonical relay URL registered with Kiingo. Render it as the private AKS
service URL (`ws://buzz:3000`) so listener traffic does not depend on Front
Door, public DNS, or internet hairpinning. `BUZZ_CANONICAL_RELAY_URL` remains
`wss://chat.kiingo.com`: buzz-acp dials the private service but uses the
canonical authority for the WebSocket `Host` header and NIP-42/NIP-98 signing.
This preserves Buzz's host-bound community boundary before and after DNS
cutover without creating another endpoint or changing listener transport.

The ingress load balancer accepts only the Azure Front Door backend service
tag. NGINX additionally validates the exact Front Door resource ID and a
Expand Down
Loading