diff --git a/crates/op-host-desktop/src/collab_runtime/network/guest.rs b/crates/op-host-desktop/src/collab_runtime/network/guest.rs index 67fd772cd..cd2d4ad6f 100644 --- a/crates/op-host-desktop/src/collab_runtime/network/guest.rs +++ b/crates/op-host-desktop/src/collab_runtime/network/guest.rs @@ -14,7 +14,9 @@ use op_collab_transport::{ use subtle::ConstantTimeEq; use super::super::auth::{production_verifier, unix_time_ms, LocalAdmission, LocalTicketRenewer}; -use super::super::relay::{relay_guest_target, GuestConnectionRoute, GuestRelayRuntime}; +use super::super::relay::{ + relay_guest_target, report_secure_transport_failure, GuestConnectionRoute, GuestRelayRuntime, +}; use super::super::types::{ CollabRuntimeFailure, GuestNetworkCommand, NetworkEvent, TerminalNetworkEvent, }; @@ -275,7 +277,7 @@ fn run_inner( let overall_deadline = Instant::now() .checked_add(join_budget) .ok_or(CollabRuntimeFailure::Transport)?; - let (prelude, mut connection) = connect_address_sequence_cancellable( + let connected = connect_address_sequence_cancellable( &addresses, overall_deadline, key.as_ref(), @@ -283,8 +285,18 @@ fn run_inner( expected_remote_static.as_ref(), config, cancellation, - ) - .map_err(|error| relay_join_failure(&error, relay_join))?; + ); + let (prelude, mut connection) = match connected { + Ok(connected) => connected, + Err(error) => { + let failure = relay_join_failure(&error, relay_join); + if let Some(relay) = relay_runtime.as_ref() { + let (relay_phase, relay_failure) = relay.bridge_diagnostic(); + report_secure_transport_failure(failure, relay_phase, relay_failure); + } + return Err(failure); + } + }; let remote_static = *connection.remote_static(); let (hello, expected_issuer, expected_subject) = { let local = local diff --git a/crates/op-host-desktop/src/collab_runtime/relay.rs b/crates/op-host-desktop/src/collab_runtime/relay.rs index 2c0608f95..f4a11b4be 100644 --- a/crates/op-host-desktop/src/collab_runtime/relay.rs +++ b/crates/op-host-desktop/src/collab_runtime/relay.rs @@ -26,6 +26,9 @@ use super::relay_bootstrap::{ }; use super::types::{CollabRuntimeError, CollabRuntimeFailure}; +mod guest_runtime; +pub(in crate::collab_runtime) use guest_runtime::report_secure_transport_failure; + const RELAY_HOME_REGION_ENV: &str = "OPENPENCIL_COLLAB_RELAY_HOME_REGION"; #[cfg(any(test, debug_assertions))] const RELAY_DEV_UNSIGNED_ENV: &str = "OPENPENCIL_COLLAB_RELAY_DEV_UNSIGNED"; @@ -324,95 +327,6 @@ pub(super) struct GuestRelayRuntime { bridge: RelayGuestBridge, } -impl GuestRelayRuntime { - pub(super) fn start( - request: &RelayGuestRequest, - key: std::sync::Arc, - local: std::sync::Arc>, - ) -> Result { - // Resolve and verify the invite on the guest network worker. The UI - // only parses enough of the bounded invite to select its claimed - // region and render status. - let bootstrap = request.provider.load()?; - let (invite, home_region) = match &request.secret { - RelayJoinSecret::Invite(invite) => (invite.as_ref().clone(), request.home_region), - RelayJoinSecret::Pairing(code) => { - let invite = claim_pairing_invite( - &bootstrap, - code, - request.control_plane.as_ref(), - &key, - &local, - )?; - let claimed_region = invite.locator().claims().home_region(); - (invite, claimed_region) - } - }; - let region = bootstrap.region(home_region)?; - let endpoint = region.relay_endpoint.clone(); - let development_unsigned = development_unsigned_allowed(&endpoint); - let now = unix_time_ms().map_err(|_| CollabRuntimeFailure::RelayUnavailable)? / 1_000; - let route = if development_unsigned { - invite - .verify(&AcceptAllDevelopmentLocator, now) - .map_err(invite_verify_failure)? - } else { - invite - .verify(®ion.locator_verifier, now) - .map_err(invite_verify_failure)? - }; - // No region cross-check here: `home_region` is itself derived from the - // (canonically parsed) invite claims on both branches, so the verify - // step above is the authoritative gate. - let expected_discovery_id = route - .locator() - .claims() - .expected_discovery_id() - .as_str() - .to_owned(); - let expected_remote_static = *route.locator().claims().owner_noise_static().as_bytes(); - let auth = LocalAdmission::relay_auth_extension(*key.public_key()) - .map_err(|error| error.failure)?; - let handshake = RelayHandshake::new(route, auth); - let authenticator = if development_unsigned { - None - } else { - Some( - LocalAdmission::challenge_bound_relay_authenticator( - local, - key, - Arc::clone(®ion.relay_x25519_keys), - ) - .map_err(|error| error.failure)?, - ) - }; - let bridge = op_host_services::chat_runtime::block_on_anywhere(async move { - if development_unsigned { - start_development_guest_bridge(endpoint, handshake).await - } else { - RelayGuestBridge::start( - endpoint, - handshake, - authenticator.ok_or(CollabRuntimeFailure::RelayUnavailable)?, - ) - .await - .map_err(|_| CollabRuntimeFailure::RelayUnavailable) - } - })?; - let local_addr = bridge.local_addr(); - Ok(Self { - local_addr, - expected_discovery_id, - expected_remote_static, - bridge, - }) - } - - pub(super) const fn local_addr(&self) -> SocketAddr { - self.local_addr - } -} - impl std::fmt::Debug for GuestRelayRuntime { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter diff --git a/crates/op-host-desktop/src/collab_runtime/relay/guest_runtime.rs b/crates/op-host-desktop/src/collab_runtime/relay/guest_runtime.rs new file mode 100644 index 000000000..6055c261f --- /dev/null +++ b/crates/op-host-desktop/src/collab_runtime/relay/guest_runtime.rs @@ -0,0 +1,248 @@ +use super::*; + +/// Coarse, credential-free startup stages for diagnosing a public Relay join. +/// +/// Never add request data, endpoint URLs, invite material, identities, keys, or +/// raw transport errors to this enum. Its `Debug` output is emitted to the +/// local terminal when startup fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RelayGuestStartupStage { + Bootstrap, + PairingClaim, + RegionSelection, + Clock, + InviteVerification, + LocalAuthentication, + RelayBridge, +} + +struct RelayGuestStageFailure { + stage: RelayGuestStartupStage, + failure: CollabRuntimeFailure, +} + +impl std::fmt::Display for RelayGuestStageFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "RelayGuestStageFailed {{ stage: {:?}, failure: {:?} }}", + self.stage, self.failure + ) + } +} + +fn at_stage( + stage: RelayGuestStartupStage, + result: Result, +) -> Result { + at_stage_with(stage, result, |diagnostic| { + eprintln!("[collab] {diagnostic}"); + }) +} + +fn at_stage_with( + stage: RelayGuestStartupStage, + result: Result, + report: impl FnOnce(RelayGuestStageFailure), +) -> Result { + match result { + Ok(value) => Ok(value), + Err(failure) => { + report(RelayGuestStageFailure { stage, failure }); + Err(failure) + } + } +} + +struct RelayGuestTransportFailure { + failure: CollabRuntimeFailure, + relay_phase: op_collab_relay_client::RelayBridgePhase, + relay_failure: Option, +} + +impl std::fmt::Display for RelayGuestTransportFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "RelayGuestStageFailed {{ stage: SecureTransport, failure: {:?}, relay_phase: {:?}, \ + relay_failure: {:?} }}", + self.failure, self.relay_phase, self.relay_failure + ) + } +} + +pub(in crate::collab_runtime) fn report_secure_transport_failure( + failure: CollabRuntimeFailure, + relay_phase: op_collab_relay_client::RelayBridgePhase, + relay_failure: Option, +) { + let diagnostic = RelayGuestTransportFailure { + failure, + relay_phase, + relay_failure, + }; + eprintln!("[collab] {diagnostic}"); +} + +impl GuestRelayRuntime { + pub(in crate::collab_runtime) fn start( + request: &RelayGuestRequest, + key: Arc, + local: Arc>, + ) -> Result { + // Resolve and verify the invite on the guest network worker. The UI + // only parses enough of the bounded invite to select its claimed + // region and render status. + let bootstrap = at_stage(RelayGuestStartupStage::Bootstrap, request.provider.load())?; + let (invite, home_region) = match &request.secret { + RelayJoinSecret::Invite(invite) => (invite.as_ref().clone(), request.home_region), + RelayJoinSecret::Pairing(code) => { + let invite = at_stage( + RelayGuestStartupStage::PairingClaim, + claim_pairing_invite( + &bootstrap, + code, + request.control_plane.as_ref(), + &key, + &local, + ), + )?; + let claimed_region = invite.locator().claims().home_region(); + (invite, claimed_region) + } + }; + let region = at_stage( + RelayGuestStartupStage::RegionSelection, + bootstrap.region(home_region), + )?; + let endpoint = region.relay_endpoint.clone(); + let development_unsigned = development_unsigned_allowed(&endpoint); + let now = at_stage( + RelayGuestStartupStage::Clock, + unix_time_ms().map_err(|_| CollabRuntimeFailure::RelayUnavailable), + )? / 1_000; + let route = at_stage(RelayGuestStartupStage::InviteVerification, { + if development_unsigned { + invite + .verify(&AcceptAllDevelopmentLocator, now) + .map_err(invite_verify_failure) + } else { + invite + .verify(®ion.locator_verifier, now) + .map_err(invite_verify_failure) + } + })?; + // No region cross-check here: `home_region` is itself derived from the + // (canonically parsed) invite claims on both branches, so the verify + // step above is the authoritative gate. + let expected_discovery_id = route + .locator() + .claims() + .expected_discovery_id() + .as_str() + .to_owned(); + let expected_remote_static = *route.locator().claims().owner_noise_static().as_bytes(); + let auth = at_stage( + RelayGuestStartupStage::LocalAuthentication, + LocalAdmission::relay_auth_extension(*key.public_key()).map_err(|error| error.failure), + )?; + let handshake = RelayHandshake::new(route, auth); + let authenticator = if development_unsigned { + None + } else { + Some(at_stage( + RelayGuestStartupStage::LocalAuthentication, + LocalAdmission::challenge_bound_relay_authenticator( + local, + key, + Arc::clone(®ion.relay_x25519_keys), + ) + .map_err(|error| error.failure), + )?) + }; + let bridge = at_stage( + RelayGuestStartupStage::RelayBridge, + op_host_services::chat_runtime::block_on_anywhere(async move { + if development_unsigned { + start_development_guest_bridge(endpoint, handshake).await + } else { + RelayGuestBridge::start( + endpoint, + handshake, + authenticator.ok_or(CollabRuntimeFailure::RelayUnavailable)?, + ) + .await + .map_err(|_| CollabRuntimeFailure::RelayUnavailable) + } + }), + )?; + let local_addr = bridge.local_addr(); + Ok(Self { + local_addr, + expected_discovery_id, + expected_remote_static, + bridge, + }) + } + + pub(in crate::collab_runtime) const fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + pub(in crate::collab_runtime) fn bridge_diagnostic( + &self, + ) -> ( + op_collab_relay_client::RelayBridgePhase, + Option, + ) { + let status = self.bridge.status(); + (status.phase, status.last_error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage_wrapper_emits_only_the_safe_failure_shape() { + let mut emitted = None; + let result = at_stage_with::<()>( + RelayGuestStartupStage::PairingClaim, + Err(CollabRuntimeFailure::RelayUnavailable), + |diagnostic| emitted = Some(diagnostic.to_string()), + ); + + assert_eq!(result, Err(CollabRuntimeFailure::RelayUnavailable)); + assert_eq!( + emitted.as_deref(), + Some("RelayGuestStageFailed { stage: PairingClaim, failure: RelayUnavailable }") + ); + } + + #[test] + fn successful_stage_emits_nothing() { + let mut emitted = false; + let result = at_stage_with(RelayGuestStartupStage::Bootstrap, Ok(7_u8), |_| { + emitted = true + }); + + assert_eq!(result, Ok(7)); + assert!(!emitted); + } + + #[test] + fn secure_transport_diagnostic_contains_only_safe_enums() { + let diagnostic = RelayGuestTransportFailure { + failure: CollabRuntimeFailure::RelayUnavailable, + relay_phase: op_collab_relay_client::RelayBridgePhase::Failed, + relay_failure: Some(op_collab_relay_client::RelayFailureKind::ConnectTimeout), + }; + + assert_eq!( + diagnostic.to_string(), + "RelayGuestStageFailed { stage: SecureTransport, failure: RelayUnavailable, \ + relay_phase: Failed, relay_failure: Some(ConnectTimeout) }" + ); + } +}