diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index aedd67854c..1d4e4ec18b 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -42,6 +42,9 @@ enum PairingMode { #[derive(Clone)] struct PairingTaskContext { mode: PairingMode, + /// Which NIP-11 branch produced the pairing URL. Carried purely so a + /// failed handshake can say why it dialled where it did. + route: PairingRelay, generation: Arc, generation_fence: Arc>, task_generation: u64, @@ -129,7 +132,8 @@ async fn start_pairing_session( let ws_url = relay_ws_url_with_override(&state); let http_url = relay_api_base_url_with_override(&state); - let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?; + let pairing_route = probe_pairing_relay(&ws_url).await; + let pairing_relay_url = resolve_pairing_relay_url(&ws_url, pairing_route.clone())?; let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone()); let mut qr_uri = encode_qr(&qr_payload); if mode == PairingMode::RecoverIdentity { @@ -166,6 +170,7 @@ async fn start_pairing_session( Arc::clone(&pairing.session), PairingTaskContext { mode, + route: pairing_route, generation: Arc::clone(&pairing.generation), generation_fence: Arc::clone(&pairing.generation_fence), task_generation, @@ -305,7 +310,7 @@ async fn pairing_ws_task_inner( ) -> Result<(), String> { let (ws, _) = connect_async(relay_url) .await - .map_err(|e| format!("WebSocket connection failed: {e}"))?; + .map_err(|e| describe_connect_failure(&context.route, relay_url, &e))?; let (mut write, mut read) = ws.split(); handle_nip42_auth(&mut read, &mut write, session, relay_url).await?; @@ -663,8 +668,50 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { serde_json::from_value(arr[2].clone()).ok() } +/// Explain a failed pairing handshake in terms of the route that produced the +/// URL, rather than as a bare transport error. +/// +/// The 404 case is the one worth spelling out. A relay that enforces +/// membership advertises NIP-43, and if it advertises no `pairing_relay_url` +/// then [`resolve_pairing_relay_url`] appends `/pair` to the relay's own host +/// — an address chosen from an *advertisement*, never probed. When nothing +/// serves it the user saw only `WebSocket connection failed: HTTP error: 404 +/// Not Found`, which names neither the URL that was dialled, nor the branch +/// that chose it, nor either lever that fixes it. Self-hosters have had to +/// read `config.rs` and this file to get unstuck. +/// +/// Every other failure keeps the original transport error and gains the +/// attempted URL, which is what the split-domain case needs when a configured +/// `pairing_relay_url` is simply wrong. +fn describe_connect_failure( + route: &PairingRelay, + url: &str, + error: &tokio_tungstenite::tungstenite::Error, +) -> String { + let not_found = matches!( + error, + tokio_tungstenite::tungstenite::Error::Http(response) if response.status().as_u16() == 404 + ); + + if not_found && matches!(route, PairingRelay::LegacyPath) { + return format!( + "Pairing endpoint {url} not found (404). This relay advertises NIP-43 but no \ + pairing_relay_url, so pairing fell back to {url}, and nothing is serving that \ + path. The relay operator needs to set BUZZ_PAIRING_RELAY_URL on the relay, or \ + route /pair to a buzz-pair-relay instance (see deploy/compose/README.md, \ + \"Device pairing\")." + ); + } + + format!("WebSocket connection failed: {error} (pairing endpoint: {url})") +} + /// Pairing route discovered from the main relay's NIP-11 document. -#[derive(Debug, PartialEq, Eq)] +/// +/// `Clone` so the decision can be carried into the WebSocket task alongside +/// the URL it produced: which branch was taken is most of what makes a failed +/// handshake diagnosable, and it is lost the moment this collapses to a String. +#[derive(Debug, Clone, PartialEq, Eq)] enum PairingRelay { Configured(String), LegacyPath, diff --git a/desktop/src-tauri/src/commands/pairing_relay_tests.rs b/desktop/src-tauri/src/commands/pairing_relay_tests.rs index f0e765eb9c..64d3c2d848 100644 --- a/desktop/src-tauri/src/commands/pairing_relay_tests.rs +++ b/desktop/src-tauri/src/commands/pairing_relay_tests.rs @@ -1,5 +1,6 @@ use super::{ - pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, + describe_connect_failure, pairing_relay_from_nip11, probe_pairing_relay, + resolve_pairing_relay_url, PairingRelay, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -102,3 +103,73 @@ fn main_relay_pairing_uses_main_relay_url() { assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); } + +/// Build the transport error tungstenite hands back when the upgrade request +/// is answered with an HTTP status instead of a 101. +fn http_error(status: u16) -> tokio_tungstenite::tungstenite::Error { + let response = tokio_tungstenite::tungstenite::http::Response::builder() + .status(status) + .body(None) + .expect("build HTTP error response"); + tokio_tungstenite::tungstenite::Error::Http(Box::new(response)) +} + +#[test] +fn legacy_path_404_explains_the_missing_pairing_endpoint() { + let message = describe_connect_failure( + &PairingRelay::LegacyPath, + "wss://relay.example.com/pair", + &http_error(404), + ); + + // The URL that was actually dialled, because the old message named none. + assert!(message.contains("wss://relay.example.com/pair")); + // Why this URL was chosen at all. + assert!(message.contains("NIP-43")); + assert!(message.contains("pairing_relay_url")); + // Both levers that fix it: neither was discoverable without a source dive. + assert!(message.contains("BUZZ_PAIRING_RELAY_URL")); + assert!(message.contains("buzz-pair-relay")); +} + +#[test] +fn legacy_path_non_404_keeps_the_transport_error_and_gains_the_url() { + // A routed /pair whose sidecar is down answers 502, which is a different + // problem with a different fix, so it must not claim nothing is serving + // the path. + let message = describe_connect_failure( + &PairingRelay::LegacyPath, + "wss://relay.example.com/pair", + &http_error(502), + ); + + assert!(message.contains("wss://relay.example.com/pair")); + assert!(!message.contains("BUZZ_PAIRING_RELAY_URL")); +} + +#[test] +fn configured_pairing_relay_404_reports_the_url_it_was_given() { + // The split-domain case: the operator advertised a pairing_relay_url and + // it is wrong. Naming it is the whole fix; guessing at /pair advice here + // would point at a file that has nothing to do with the failure. + let message = describe_connect_failure( + &PairingRelay::Configured("wss://pairing.example.com".to_string()), + "wss://pairing.example.com", + &http_error(404), + ); + + assert!(message.contains("wss://pairing.example.com")); + assert!(!message.contains("BUZZ_PAIRING_RELAY_URL")); +} + +#[test] +fn main_relay_failure_keeps_the_transport_error() { + let message = describe_connect_failure( + &PairingRelay::MainRelay, + "wss://relay.example.com", + &http_error(500), + ); + + assert!(message.starts_with("WebSocket connection failed:")); + assert!(message.contains("wss://relay.example.com")); +}