diff --git a/Cargo.lock b/Cargo.lock index b650097c..add5599f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1194,6 +1194,7 @@ dependencies = [ "diesel-sqlite-session", "futures-util", "getrandom 0.3.4", + "ipnet", "jni 0.21.1", "js-sys", "keyring-core", @@ -1216,6 +1217,7 @@ dependencies = [ "serde_bytes", "serde_json", "sha2 0.10.9", + "socket2", "sqlite-diff-rs", "sqlite-wasm-rs", "sqlparser", @@ -1390,6 +1392,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "connetto-peer-android" +version = "0.0.0" +dependencies = [ + "manganis", + "thiserror 2.0.20", +] + [[package]] name = "connetto-schema" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 5280b00d..329c5425 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/connetto-ca", "crates/connetto-app-attest", "crates/connetto-peer", + "crates/connetto-peer-android", # The documents and the build-time translation every browser demo shares. # A member rather than an exclusion, so the root gate lints it with the rest. "examples/deployment", diff --git a/crates/connetto-client/Cargo.toml b/crates/connetto-client/Cargo.toml index 2e59d703..3cbe4796 100644 --- a/crates/connetto-client/Cargo.toml +++ b/crates/connetto-client/Cargo.toml @@ -134,6 +134,8 @@ tokio = { version = "1", default-features = false, features = [ "macros", ] } thiserror.workspace = true +# The hotspot machine's answer, waited on by the API methods (R76). +parking_lot = "0.12" # Binary error handling (connetto-client bin). anyhow.workspace = true # Structured logging (R12). The facade only: a library emits and installs no @@ -252,6 +254,10 @@ android-native-keyring-store = { version = "1.0.0", optional = true } jni = { version = "0.21", optional = true } # The API level, since the gated store needs Android 11. android_system_properties = { version = "0.1.6", optional = true } +# The dial socket the joined network binds, through the hotspot plugin (R76). +socket2 = "0.6" +# The joined network's subnet, the targets its bind admits (R76). +ipnet = "2.12" libsqlite3-sys = { version = ">=0.38, <0.39", features = [ "bundled-sqlcipher-vendored-openssl", ] } diff --git a/crates/connetto-client/src/builder/native.rs b/crates/connetto-client/src/builder/native.rs index c5f3a1d8..8e6fbcf0 100644 --- a/crates/connetto-client/src/builder/native.rs +++ b/crates/connetto-client/src/builder/native.rs @@ -47,6 +47,8 @@ use crate::teardown::content_dir; use crate::teardown::{ForgetError, PurgeError, forget_device, wipe_replica}; use crate::{ClientError, ConnettoClient, Custody}; #[cfg(feature = "peer")] +use crate::{ClientEvent, HotspotError, HotspotOffer, JoinError}; +#[cfg(feature = "peer")] use connetto_core::device_cert::AttestationLevel; #[cfg(feature = "device-identity")] use connetto_core::device_cert::{DeviceCertificate, DeviceDescriptor, KeyHome}; @@ -696,6 +698,10 @@ where /// /// [`ClientError`] on a sign-in, key, dial, database, or handshake /// failure. + #[expect( + clippy::too_many_lines, + reason = "the device-identity, native-auth and peer seams each add their cfg-forked block" + )] pub async fn connect_with_pump( self, ) -> Result<(NativeClient, CorePump), ClientError> { @@ -738,6 +744,8 @@ where #[cfg(feature = "peer")] let (peer_events, peer_events_rx) = mpsc::unbounded_channel(); #[cfg(feature = "peer")] + let (hotspot_tx, hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] let node = connetto_peer::Node::new( connetto_peer::Trust { roots: device.roots.clone(), @@ -752,6 +760,8 @@ where #[cfg(feature = "peer")] let (discovery_events, discovery_events_rx) = mpsc::unbounded_channel(); #[cfg(feature = "peer")] + let peer_node = node.clone(); + #[cfg(feature = "peer")] let peer = Peer { node: node.clone(), discovery: connetto_peer::Discovery::new( @@ -775,8 +785,86 @@ where lists, #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let enrolment = core.client().enrolment(enroller); + #[cfg(feature = "peer")] + let hotspot_runner = { + let backend: Arc = { + #[cfg(target_os = "android")] + { + match device.java.clone() { + Some(java) => { + Arc::new(crate::hotspot::AndroidHotspotBackend::new(java)) + } + None => Arc::new(crate::hotspot::UnsupportedBackend), + } + } + #[cfg(not(target_os = "android"))] + { + Arc::new(crate::hotspot::UnsupportedBackend) + } + }; + let port_node = peer_node.clone(); + #[cfg(target_os = "android")] + let bind_node = peer_node.clone(); + let dial_handle = handle.clone(); + let event_sender = core.client().event_sender(); + #[cfg(target_os = "android")] + let bind = { + let java = device.java.clone(); + Arc::new( + move |subnet: Option<(std::net::Ipv4Addr, u8)>| match subnet { + Some(subnet) => { + if let Some(java) = &java { + bind_node.set_socket_prep(Some(Arc::new( + crate::hotspot::JoinedBind::new(java.clone(), subnet), + ))); + } + } + None => bind_node.set_socket_prep(None), + }, + ) + }; + #[cfg(not(target_os = "android"))] + let bind = Arc::new(|_| {}); + let machine = crate::hotspot::Machine::new( + backend, + device.peer_autolink, + Arc::new(move || port_node.local_addr().map(|addr| addr.port())), + bind, + Arc::new( + move |addr: SocketAddr, answer: tokio::sync::oneshot::Sender| { + let handle = dial_handle.clone(); + tokio::spawn(async move { + match handle.link_peer(addr).await { + Ok(_) => { + let _ = answer.send(true); + } + Err(err) => { + tracing::debug!( + %addr, + ?err, + "the dial to the joined hotspot's gateway failed" + ); + let _ = answer.send(false); + } + } + }); + }, + ), + Arc::new(move |event: ClientEvent| { + let _ = event_sender.send(event); + }), + ); + crate::hotspot::run(machine, hotspot_rx) + }; + #[cfg(feature = "peer")] + let pump: CorePump = Box::pin(async move { + tokio::join!(pump, enrolment, hotspot_runner); + }); + #[cfg(not(feature = "peer"))] let pump: CorePump = Box::pin(async move { tokio::join!(pump, enrolment); }); @@ -989,6 +1077,75 @@ where None => Err(PeerError::NoIdentity), } } + + /// Host this device's hotspot, answering its details or the reason it + /// will not, within the machine's bound (R76). + /// + /// # Errors + /// + /// [`HotspotError::Unsupported`] on a system without a hotspot or for a + /// build without a device identity, [`HotspotError::MissingPermission`] + /// with a permission the device has not granted, the mapped failure from + /// the device's Wi-Fi manager, and [`HotspotError::TimedOut`] when the + /// hotspot does not start within its bound. + #[cfg(feature = "peer")] + pub async fn host_hotspot(&self) -> Result { + match &self.device { + Some(device) => device.host_hotspot().await, + None => Err(HotspotError::Unsupported), + } + } + + /// Stop hosting, or cancel the pending request (R76). + /// + /// A no-op on a system without a hotspot or for a build without a device + /// identity. + #[cfg(feature = "peer")] + #[expect( + clippy::unused_async, + clippy::unused_async_trait_impl, + reason = "uniform awaited hotspot API, the command send is synchronous" + )] + pub async fn stop_hotspot(&self) { + if let Some(device) = &self.device { + device.stop_hotspot(); + } + } + + /// Join `offer`'s network, answering its gateway or the reason it will + /// not, within the machine's bound (R76). + /// + /// # Errors + /// + /// [`JoinError::Unsupported`] on a system without a hotspot or for a + /// build without a device identity, [`JoinError::MissingPermission`] + /// with a permission the device has not granted, + /// [`JoinError::Declined`] when the network is not available, and + /// [`JoinError::TimedOut`] when the network does not answer within its + /// bound. + #[cfg(feature = "peer")] + pub async fn join_hotspot(&self, offer: &HotspotOffer) -> Result { + match &self.device { + Some(device) => device.join_hotspot(offer).await, + None => Err(JoinError::Unsupported), + } + } + + /// Leave the joined network, or cancel the pending request (R76). + /// + /// A no-op on a system without a hotspot or for a build without a device + /// identity. + #[cfg(feature = "peer")] + #[expect( + clippy::unused_async, + clippy::unused_async_trait_impl, + reason = "uniform awaited hotspot API, the command send is synchronous" + )] + pub async fn leave_hotspot(&self) { + if let Some(device) = &self.device { + device.leave_hotspot(); + } + } } #[cfg(feature = "native-auth")] diff --git a/crates/connetto-client/src/enrolment/task.rs b/crates/connetto-client/src/enrolment/task.rs index 7d2b76a4..9af9d316 100644 --- a/crates/connetto-client/src/enrolment/task.rs +++ b/crates/connetto-client/src/enrolment/task.rs @@ -31,6 +31,8 @@ use super::{Answer, Held, KeptList, Standing, TOLERANCE, half_life}; #[cfg(feature = "peer")] use crate::PeerError; use crate::device_key::{ChipError, ChipKeys, KeyRecords, OpenedKey}; +#[cfg(feature = "peer")] +use crate::hotspot::{HOST_BOUND, HotspotError, HotspotOffer, JOIN_BOUND, JoinError, MARGIN}; #[cfg(all(feature = "peer", target_os = "android"))] use crate::multicast::MulticastLock; use crate::{ClientError, ClientEvent}; @@ -278,6 +280,7 @@ pub(crate) struct Enroller { } /// The application's half, held by the native client. +#[derive(Clone)] pub(crate) struct EnrolHandle { keys: Arc, commands: mpsc::UnboundedSender, @@ -289,6 +292,9 @@ pub(crate) struct EnrolHandle { clock_off: Arc, #[cfg(feature = "peer")] peer: connetto_peer::Node, + #[cfg(feature = "peer")] + /// The hotspot machine's commands, until it ends with the client (R76). + hotspot: mpsc::UnboundedSender, } impl Enroller { @@ -301,7 +307,7 @@ impl Enroller { feature = "peer", expect( clippy::too_many_arguments, - reason = "the peer node joins the seven enrolment inputs and a config struct would hide the same arity behind another type" + reason = "the peer node and its hotspot channel join the seven enrolment inputs and a config struct would hide the same arity behind another type" ) )] pub(crate) fn new( @@ -313,6 +319,7 @@ impl Enroller { kept: Vec, lists: super::ListInbox, #[cfg(feature = "peer")] peer: Peer, + #[cfg(feature = "peer")] hotspot: mpsc::UnboundedSender, ) -> (Self, EnrolHandle) { let (sender, commands) = mpsc::unbounded_channel(); let (published, observed) = watch::channel(held.as_ref().map(|held| held.leaf.clone())); @@ -345,6 +352,8 @@ impl Enroller { clock_off, #[cfg(feature = "peer")] peer: peer_node, + #[cfg(feature = "peer")] + hotspot, }, ) } @@ -426,6 +435,54 @@ impl EnrolHandle { }, } } + + /// Host this device's hotspot, answered with its details or the reason + /// it will not, within the machine's bound (R76). + #[cfg(feature = "peer")] + pub(crate) async fn host_hotspot(&self) -> Result { + let (reply, answer) = oneshot::channel(); + self.hotspot + .send(crate::hotspot::Command::Host(reply)) + .map_err(|_| HotspotError::Failed)?; + match tokio::time::timeout(HOST_BOUND + MARGIN, answer).await { + Ok(Ok(offer)) => offer, + Ok(Err(_)) => Err(HotspotError::Failed), + Err(_) => Err(HotspotError::TimedOut), + } + } + + /// Stop hosting, or cancel the pending request (R76). + #[cfg(feature = "peer")] + pub(crate) fn stop_hotspot(&self) { + let _ = self.hotspot.send(crate::hotspot::Command::StopHost); + } + + /// Join `offer`'s network, answered with its gateway, within the + /// machine's bound (R76). + #[cfg(feature = "peer")] + pub(crate) async fn join_hotspot( + &self, + offer: &HotspotOffer, + ) -> Result { + let (reply, answer) = oneshot::channel(); + self.hotspot + .send(crate::hotspot::Command::Join { + offer: offer.clone(), + reply, + }) + .map_err(|_| JoinError::Failed)?; + match tokio::time::timeout(JOIN_BOUND + MARGIN, answer).await { + Ok(Ok(gateway)) => gateway, + Ok(Err(_)) => Err(JoinError::Failed), + Err(_) => Err(JoinError::TimedOut), + } + } + + /// Leave the joined network, or cancel the pending request (R76). + #[cfg(feature = "peer")] + pub(crate) fn leave_hotspot(&self) { + let _ = self.hotspot.send(crate::hotspot::Command::Leave); + } } /// How long to sleep before looking at `held` again. diff --git a/crates/connetto-client/src/enrolment/tests.rs b/crates/connetto-client/src/enrolment/tests.rs index bd37598a..168bce9b 100644 --- a/crates/connetto-client/src/enrolment/tests.rs +++ b/crates/connetto-client/src/enrolment/tests.rs @@ -665,6 +665,8 @@ async fn a_refused_attestation_is_raised_once_and_asks_again_only_on_the_next_co gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, _handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -675,6 +677,8 @@ async fn a_refused_attestation_is_raised_once_and_asks_again_only_on_the_next_co inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -748,6 +752,8 @@ async fn a_renewal_sends_no_attestation() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, _handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -758,6 +764,8 @@ async fn a_renewal_sends_no_attestation() { inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -801,6 +809,8 @@ async fn a_reissue_refused_as_revoked_deletes_the_key_and_reports_revoked() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -811,6 +821,8 @@ async fn a_reissue_refused_as_revoked_deletes_the_key_and_reports_revoked() { inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -857,6 +869,8 @@ async fn a_reissue_refused_over_the_ceiling_reports_the_ceiling() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -867,6 +881,8 @@ async fn a_reissue_refused_over_the_ceiling_reports_the_ceiling() { inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -906,6 +922,8 @@ async fn a_reissue_refused_for_another_reason_reports_that_reason() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -916,6 +934,8 @@ async fn a_reissue_refused_for_another_reason_reports_that_reason() { inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -954,6 +974,8 @@ async fn a_reissue_with_no_answer_is_offline() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -964,6 +986,8 @@ async fn a_reissue_with_no_answer_is_offline() { inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1004,6 +1028,8 @@ async fn a_reissue_lost_on_the_wire_is_offline() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1014,6 +1040,8 @@ async fn a_reissue_lost_on_the_wire_is_offline() { inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1052,6 +1080,8 @@ async fn a_reissue_violating_the_protocol_is_a_device_error() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1062,6 +1092,8 @@ async fn a_reissue_violating_the_protocol_is_a_device_error() { inbox(), #[cfg(feature = "peer")] peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1165,16 +1197,50 @@ async fn far_closes( ) -> Vec { let until = Instant::now() + bound; let mut reasons = Vec::new(); - while Instant::now() < until { - match tokio::time::timeout(Duration::from_millis(50), rx.recv()).await { - Ok(Some(connetto_peer::PeerEvent::Unlinked { reason, .. })) => reasons.push(reason), - Ok(_) => {} - Err(_) => break, + loop { + let remaining = until.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + // The drain waits out the whole bound, so a close that arrives + // after a long gap still lands. + let Ok(Some(event)) = tokio::time::timeout(remaining, rx.recv()).await else { + // The bound ran out or the sender dropped, so nothing else is + // in time. + break; + }; + if let connetto_peer::PeerEvent::Unlinked { reason, .. } = event { + reasons.push(reason); } } reasons } +/// The far drain runs for the whole bound, so a close that arrives after a +/// long gap still lands. +#[cfg(feature = "peer")] +#[tokio::test] +async fn far_closes_drains_for_the_whole_bound() { + use connetto_core::device_cert::{DeploymentId, DeviceIdentity, KeyId}; + let (tx, mut rx) = mpsc::unbounded_channel(); + let peer = DeviceIdentity::new( + DeploymentId::from_uuid(uuid::Uuid::from_u128(1)), + "peer", + KeyId::from_bytes([1; 32]), + ) + .expect("an identity holds"); + let late = tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + let _ = tx.send(connetto_peer::PeerEvent::Unlinked { + peer, + reason: connetto_peer::CloseReason::Closed, + }); + }); + let reasons = far_closes(&mut rx, Duration::from_secs(3)).await; + late.await.expect("the sender ends"); + assert_eq!(reasons, [connetto_peer::CloseReason::Closed]); +} + /// The open of a fresh device serves the peer listener (R76 proof 1). #[cfg(feature = "peer")] #[tokio::test] @@ -1198,6 +1264,8 @@ async fn a_fresh_device_serves_its_peer_listener_at_open() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1207,6 +1275,8 @@ async fn a_fresh_device_serves_its_peer_listener_at_open() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1241,6 +1311,8 @@ async fn an_expired_device_refuses_to_link_and_raises_the_event() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1250,6 +1322,8 @@ async fn an_expired_device_refuses_to_link_and_raises_the_event() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1302,6 +1376,8 @@ async fn a_clock_off_device_refuses_to_link_outside_its_window() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1311,6 +1387,8 @@ async fn a_clock_off_device_refuses_to_link_outside_its_window() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1376,6 +1454,8 @@ async fn a_grant_past_its_window_refuses_the_dial_as_a_window() { gate: None, grant_from: Some(now - 13 * HOUR), }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1385,6 +1465,8 @@ async fn a_grant_past_its_window_refuses_the_dial_as_a_window() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1444,6 +1526,8 @@ async fn a_certificate_less_device_refuses_to_link() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1453,6 +1537,8 @@ async fn a_certificate_less_device_refuses_to_link() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1508,6 +1594,8 @@ async fn a_look_at_the_expiry_closes_the_live_peer_links() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1517,6 +1605,8 @@ async fn a_look_at_the_expiry_closes_the_live_peer_links() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1603,6 +1693,8 @@ async fn a_granted_renewal_keeps_the_peer_port_and_the_live_link() { gate: Some(gate.clone()), grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1612,6 +1704,8 @@ async fn a_granted_renewal_keeps_the_peer_port_and_the_live_link() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1706,6 +1800,8 @@ async fn a_peer_list_revoking_the_own_serial_revokes_the_device() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1715,6 +1811,8 @@ async fn a_peer_list_revoking_the_own_serial_revokes_the_device() { Vec::new(), inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1833,6 +1931,8 @@ async fn a_stale_peer_list_changes_nothing() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1842,6 +1942,8 @@ async fn a_stale_peer_list_changes_nothing() { vec![kept], inbox(), peer, + #[cfg(feature = "peer")] + hotspot_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1949,6 +2051,8 @@ async fn two_clients_discover_each_other_and_autolink() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller_a, _handle_a) = Enroller::new( Arc::new(RunKeys { der: der_a }), Some(HOUR), @@ -1958,7 +2062,11 @@ async fn two_clients_discover_each_other_and_autolink() { Vec::new(), inbox(), peer_a, + #[cfg(feature = "peer")] + hotspot_tx, ); + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller_b, _handle_b) = Enroller::new( Arc::new(RunKeys { der: der_b }), Some(HOUR), @@ -1968,6 +2076,8 @@ async fn two_clients_discover_each_other_and_autolink() { Vec::new(), inbox(), peer_b, + #[cfg(feature = "peer")] + hotspot_tx, ); let run_a = tokio::spawn(run(link_a, enroller_a)); let run_b = tokio::spawn(run(link_b, enroller_b)); @@ -2035,6 +2145,8 @@ async fn clients_without_autolink_report_and_link_only_on_the_call() { gate: None, grant_from: None, }; + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller_a, handle_a) = Enroller::new( Arc::new(RunKeys { der: der_a }), Some(HOUR), @@ -2044,7 +2156,11 @@ async fn clients_without_autolink_report_and_link_only_on_the_call() { Vec::new(), inbox(), peer_a, + #[cfg(feature = "peer")] + hotspot_tx, ); + #[cfg(feature = "peer")] + let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); let (enroller_b, _handle_b) = Enroller::new( Arc::new(RunKeys { der: der_b }), Some(HOUR), @@ -2054,6 +2170,8 @@ async fn clients_without_autolink_report_and_link_only_on_the_call() { Vec::new(), inbox(), peer_b, + #[cfg(feature = "peer")] + hotspot_tx, ); let run_a = tokio::spawn(run(link_a, enroller_a)); let run_b = tokio::spawn(run(link_b, enroller_b)); diff --git a/crates/connetto-client/src/hotspot.rs b/crates/connetto-client/src/hotspot.rs new file mode 100644 index 00000000..8449b3eb --- /dev/null +++ b/crates/connetto-client/src/hotspot.rs @@ -0,0 +1,1532 @@ +//! The hosted and joined hotspots, behind the two tables that steer them +//! (R76). +//! +//! The machine is a small loop. An API method sends a command, the machine +//! asks the backend, looks at its outcome on a tick, and applies the table's +//! transitions to the answer, the events and the seams the client gave it. +//! The proofs drive the tables through a fake backend, and the Android +//! backend reaches the bundled plugin through the application's JNI access. + +use std::fmt; +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(test)] +use parking_lot::Mutex; +use tokio::sync::{mpsc, oneshot}; +use zeroize::Zeroize; + +use crate::ClientEvent; + +#[cfg(target_os = "android")] +mod android; + +#[cfg(target_os = "android")] +pub(crate) use android::{AndroidHotspotBackend, JoinedBind}; + +/// The time a hosted hotspot gets to start. +pub(crate) const HOST_BOUND: Duration = Duration::from_secs(30); +/// The time a join gets, approval included. +pub(crate) const JOIN_BOUND: Duration = Duration::from_secs(60); +/// The margin the API methods give the machine past its bound. +pub(crate) const MARGIN: Duration = Duration::from_secs(5); +/// The time the joined hotspot's gateway gets for a link, after the join. +pub(crate) const DIAL_BOUND: Duration = Duration::from_secs(60); +/// The wait between a failed gateway dial and its redial. +pub(crate) const REDIAL: Duration = Duration::from_secs(2); +/// The pace of the machine's look at the backend's outcome. +const TICK: Duration = Duration::from_millis(250); + +/// The security a hotspot's Wi-Fi carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HotspotSecurity { + /// WPA2 personal, a passphrase. + Wpa2, + /// WPA3 personal, a passphrase. + Wpa3, +} + +/// The details a hosted hotspot hands to its joiners. +#[derive(Clone, PartialEq, Eq)] +pub struct HotspotOffer { + /// The network name. + pub ssid: String, + /// The passphrase, redacted in the debug form and zeroed on drop. + pub passphrase: String, + /// The security the network carries. + pub security: HotspotSecurity, + /// The peer port the host serves, while the device serves it. + pub port: Option, +} + +impl HotspotOffer { + /// An offer with its details. + #[must_use] + pub fn new( + ssid: impl Into, + passphrase: impl Into, + security: HotspotSecurity, + port: Option, + ) -> Self { + Self { + ssid: ssid.into(), + passphrase: passphrase.into(), + security, + port, + } + } +} + +impl fmt::Debug for HotspotOffer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HotspotOffer") + .field("ssid", &self.ssid) + .field("passphrase", &"") + .field("security", &self.security) + .field("port", &self.port) + .finish() + } +} + +impl Drop for HotspotOffer { + fn drop(&mut self) { + self.passphrase.zeroize(); + } +} + +/// The reasons a hotspot will not host (R76). +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum HotspotError { + /// This system cannot host a hotspot. + #[error("this system cannot host a hotspot")] + Unsupported, + /// The permission `name` is not granted. + #[error("the permission {0} is not granted")] + MissingPermission(String), + /// The device cannot host a hotspot in its current mode. + #[error("the device cannot host a hotspot in its current mode")] + Incompatible, + /// No channel is available for the hotspot. + #[error("no channel is available for the hotspot")] + NoChannel, + /// Tethering is disallowed. + #[error("tethering is disallowed")] + Disallowed, + /// The hotspot did not start within its bound. + #[error("the hotspot did not start within its bound")] + TimedOut, + /// The hotspot failed to start. + #[error("the hotspot failed to start")] + Failed, +} + +/// The reasons a hotspot will not join (R76). +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum JoinError { + /// This system cannot join a hotspot. + #[error("this system cannot join a hotspot")] + Unsupported, + /// The permission `name` is not granted. + #[error("the permission {0} is not granted")] + MissingPermission(String), + /// The network is not available to this device. + #[error("the network is not available to this device")] + Declined, + /// The network did not answer within its bound. + #[error("the network did not answer within its bound")] + TimedOut, + /// The join failed. + #[error("the join failed")] + Failed, +} + +/// The host side's outcome, as the backend stands it (R76). +#[cfg_attr( + not(any(target_os = "android", test)), + expect( + dead_code, + reason = "the Android backend and the proofs construct these standings, a non-Android build reads them through the tables only" + ) +)] +#[derive(Clone)] +pub(crate) enum HostStatus { + /// Nothing requested, or the request in flight. + Pending, + /// The hotspot is up, with its credentials. + Started { + /// The network name. + ssid: String, + /// The passphrase. + passphrase: String, + /// The security the network carries. + security: HotspotSecurity, + }, + /// The hotspot failed, with the mapped reason. + Failed(HotspotError), + /// The system or the user stopped it. + Stopped, +} + +/// The join side's outcome, as the backend stands it (R76). +#[cfg_attr( + not(any(target_os = "android", test)), + expect( + dead_code, + reason = "the Android backend and the proofs construct these standings, a non-Android build reads them through the tables only" + ) +)] +#[derive(Clone)] +pub(crate) enum JoinStatus { + /// Nothing requested, or the request in flight. + Pending, + /// The network is up, with its subnet and gateway. + Available { + /// The device's address on the network. + address: Ipv4Addr, + /// The subnet's prefix length. + prefix: u8, + /// The network's gateway. + gateway: IpAddr, + }, + /// The system will not give the network. + Unavailable, + /// The joined network is gone. + Lost, +} + +/// The backend's side of the hotspot tables, one per system (R76). +pub(crate) trait HotspotBackend: Send + Sync { + /// Request the hotspot, answering a missing permission or `Unsupported` + /// at once. + fn request_host(&self) -> Result<(), HotspotError>; + /// Stop hosting, or cancel the pending request. + fn stop_host(&self); + /// What the host side stands, for the machine's look. + fn host_status(&self) -> HostStatus; + /// Request the offer's network, answering a missing permission or + /// `Unsupported` at once. + fn request_join(&self, offer: &HotspotOffer) -> Result<(), JoinError>; + /// Leave the joined network, or cancel the pending request. + fn leave_join(&self); + /// What the join side stands, for the machine's look. + fn join_status(&self) -> JoinStatus; +} + +/// The backend for a system without a hotspot (R76). +pub(crate) struct UnsupportedBackend; + +impl HotspotBackend for UnsupportedBackend { + fn request_host(&self) -> Result<(), HotspotError> { + Err(HotspotError::Unsupported) + } + + fn stop_host(&self) {} + + fn host_status(&self) -> HostStatus { + HostStatus::Pending + } + + fn request_join(&self, _offer: &HotspotOffer) -> Result<(), JoinError> { + Err(JoinError::Unsupported) + } + + fn leave_join(&self) {} + + fn join_status(&self) -> JoinStatus { + JoinStatus::Pending + } +} + +/// The one-shot answer the machine sends, the API methods awaiting it under +/// their bound (R76). +pub(crate) type Reply = oneshot::Sender; + +/// The host side's standing (R76). +enum HostState { + /// Not hosting, nothing requested. + Off, + /// The request in flight, with its answers and its bound. + Starting { + replies: Vec>>, + deadline: tokio::time::Instant, + }, + /// Hosting, with the details the answers hand out. + Hosting { offer: HotspotOffer }, +} + +/// The join side's standing (R76). +enum JoinState { + /// Not joined, nothing requested. + Idle, + /// The request in flight, with its answer, the offer and its bound. + Requesting { + offer: HotspotOffer, + reply: Reply>, + deadline: tokio::time::Instant, + }, + /// Joined, with the gateway's dial standing beside it. + Joined { + /// The dial's standing, absent without autolink or a port, and + /// dropped once a link stands or the redial gives up. + dial: Option, + }, +} + +/// The joined hotspot's dial standing, the gateway without a link (R76). +struct DialPlan { + /// The address the dials reach. + address: SocketAddr, + /// The bound, the join's origin. + bound: tokio::time::Instant, + /// The dial in flight, its issue and its answer, none while a redial + /// waits. + in_flight: Option, + /// The redial's due, after a failed dial. + next: Option, +} + +/// A dial in flight, from its issue to its answer (R76). +struct InFlight { + /// The issue's instant, the redial's origin. + issued: tokio::time::Instant, + /// The dial's answer. + answer: oneshot::Receiver, +} + +/// The hotspot machine's commands, from the client's API (R76). +pub(crate) enum Command { + /// Host the hotspot, answered with its details or the reason it will not. + Host(Reply>), + /// Stop hosting. + StopHost, + /// Join the offer's network, answered with its gateway. + Join { + /// The network to join. + offer: HotspotOffer, + /// The answer, once the network is up. + reply: Reply>, + }, + /// Leave the joined network. + Leave, +} + +/// Bind the dials' sockets to a joined subnet, or stop binding (R76). +type SubnetBind = Arc) + Send + Sync>; + +/// The hotspot machine, one per client (R76). +pub(crate) struct Machine { + backend: Arc, + autolink: bool, + /// The peer port the device serves, from its listener's standing. + peer_port: Arc Option + Send + Sync>, + /// Bind the dials' sockets to `subnet`, or stop binding with `None`. + bind: SubnetBind, + /// Dial one gateway address, answering `answer` whether it linked. + dial: Arc) + Send + Sync>, + /// Emit an event to the application. + emit: Arc, + host: HostState, + join: JoinState, +} + +impl Machine { + /// The machine over `backend`, the peer port, subnet bind, dial and + /// event seams and the device's autolink standing. + pub(crate) fn new( + backend: Arc, + autolink: bool, + peer_port: Arc Option + Send + Sync>, + bind: SubnetBind, + dial: Arc) + Send + Sync>, + emit: Arc, + ) -> Self { + Self { + backend, + autolink, + peer_port, + bind, + dial, + emit, + host: HostState::Off, + join: JoinState::Idle, + } + } + + /// Issue the first dial to `address`, standing its plan from the join. + fn start_dial(&mut self, address: SocketAddr) -> DialPlan { + let (answer, receiver) = oneshot::channel(); + (self.dial)(address, answer); + let now = tokio::time::Instant::now(); + DialPlan { + address, + bound: now + DIAL_BOUND, + in_flight: Some(InFlight { + issued: now, + answer: receiver, + }), + next: None, + } + } + + /// Take `command` and steer the tables to their rows. + pub(crate) fn handle(&mut self, command: Command) { + match command { + Command::Host(reply) => { + if matches!(self.host, HostState::Off) { + match self.backend.request_host() { + Err(err) => { + let _ = reply.send(Err(err)); + } + Ok(()) => { + self.host = HostState::Starting { + replies: vec![reply], + deadline: tokio::time::Instant::now() + HOST_BOUND, + }; + } + } + } else if let HostState::Starting { replies, .. } = &mut self.host { + replies.push(reply); + } else if let HostState::Hosting { offer } = &mut self.host { + let mut answer = offer.clone(); + answer.port = (self.peer_port)(); + let _ = reply.send(Ok(answer)); + } + } + Command::StopHost => match core::mem::replace(&mut self.host, HostState::Off) { + HostState::Off => {} + HostState::Starting { replies, .. } => { + self.backend.stop_host(); + for reply in replies { + let _ = reply.send(Err(HotspotError::Failed)); + } + } + HostState::Hosting { .. } => self.backend.stop_host(), + }, + Command::Join { offer, reply } => { + // The earlier request cancelled, the joined network + // released and its bind stopped, before the new request + // goes to the tables. + match core::mem::replace(&mut self.join, JoinState::Idle) { + JoinState::Idle => {} + JoinState::Requesting { reply: old, .. } => { + self.backend.leave_join(); + let _ = old.send(Err(JoinError::Declined)); + } + JoinState::Joined { .. } => { + self.backend.leave_join(); + (self.bind)(None); + } + } + if let Err(err) = self.backend.request_join(&offer) { + let _ = reply.send(Err(err)); + } else { + self.join = JoinState::Requesting { + offer, + reply, + deadline: tokio::time::Instant::now() + JOIN_BOUND, + }; + } + } + Command::Leave => match core::mem::replace(&mut self.join, JoinState::Idle) { + JoinState::Idle => {} + JoinState::Requesting { reply, .. } => { + self.backend.leave_join(); + let _ = reply.send(Err(JoinError::Declined)); + } + JoinState::Joined { .. } => { + self.backend.leave_join(); + (self.bind)(None); + } + }, + } + } + + /// Look at the backends' outcomes and apply the tables' transitions. + #[expect( + clippy::too_many_lines, + reason = "one tick walks the host and join tables' rows in order" + )] + pub(crate) fn tick(&mut self) { + if matches!(self.host, HostState::Starting { .. }) { + match self.backend.host_status() { + HostStatus::Started { + ssid, + passphrase, + security, + } => { + let HostState::Starting { replies, .. } = + core::mem::replace(&mut self.host, HostState::Off) + else { + unreachable!("the host side left Starting between the look and the take"); + }; + let offer = HotspotOffer::new(ssid, passphrase, security, (self.peer_port)()); + self.host = HostState::Hosting { + offer: offer.clone(), + }; + for reply in replies { + let _ = reply.send(Ok(offer.clone())); + } + } + HostStatus::Failed(err) => { + let HostState::Starting { replies, .. } = + core::mem::replace(&mut self.host, HostState::Off) + else { + unreachable!("the host side left Starting between the look and the take"); + }; + for reply in replies { + let _ = reply.send(Err(err.clone())); + } + } + HostStatus::Stopped => { + let HostState::Starting { replies, .. } = + core::mem::replace(&mut self.host, HostState::Off) + else { + unreachable!("the host side left Starting between the look and the take"); + }; + for reply in replies { + let _ = reply.send(Err(HotspotError::Failed)); + } + } + HostStatus::Pending => {} + } + if let HostState::Starting { deadline, .. } = &self.host { + // A real answer beats the bound, so the look comes first. + if tokio::time::Instant::now() >= *deadline { + let HostState::Starting { replies, .. } = + core::mem::replace(&mut self.host, HostState::Off) + else { + unreachable!("the host side left Starting between the look and the take"); + }; + self.backend.stop_host(); + for reply in replies { + let _ = reply.send(Err(HotspotError::TimedOut)); + } + } + } + } else if matches!(self.host, HostState::Hosting { .. }) + && matches!(self.backend.host_status(), HostStatus::Stopped) + { + self.host = HostState::Off; + (self.emit)(ClientEvent::HotspotStopped); + } + + if matches!(self.join, JoinState::Requesting { .. }) { + match self.backend.join_status() { + JoinStatus::Available { + address, + prefix, + gateway, + } => { + let JoinState::Requesting { offer, reply, .. } = + core::mem::replace(&mut self.join, JoinState::Idle) + else { + unreachable!("the join side left Requesting between the look and the take"); + }; + (self.bind)(Some((address, prefix))); + let dial = if self.autolink { + offer + .port + .map(|port| self.start_dial(SocketAddr::new(gateway, port))) + } else { + None + }; + self.join = JoinState::Joined { dial }; + let _ = reply.send(Ok(gateway)); + } + JoinStatus::Unavailable | JoinStatus::Lost => { + let JoinState::Requesting { reply, .. } = + core::mem::replace(&mut self.join, JoinState::Idle) + else { + unreachable!("the join side left Requesting between the look and the take"); + }; + let _ = reply.send(Err(JoinError::Declined)); + } + JoinStatus::Pending => {} + } + let due = matches!( + &self.join, + JoinState::Requesting { deadline, .. } if *deadline <= tokio::time::Instant::now() + ); + if due { + let JoinState::Requesting { reply, .. } = + core::mem::replace(&mut self.join, JoinState::Idle) + else { + unreachable!("the join side left Requesting between the look and the take"); + }; + self.backend.leave_join(); + let _ = reply.send(Err(JoinError::TimedOut)); + } + } else if matches!(self.join, JoinState::Joined { .. }) + && matches!(self.backend.join_status(), JoinStatus::Lost) + { + self.join = JoinState::Idle; + (self.bind)(None); + (self.emit)(ClientEvent::HotspotLeft); + } + + // The gateway's dial standing, after the rows that end the join. + let mut ended = false; + let mut unreached = false; + if let JoinState::Joined { dial: Some(plan) } = &mut self.join { + let now = tokio::time::Instant::now(); + let mut linked = false; + if let Some(mut in_flight) = plan.in_flight.take() { + match in_flight.answer.try_recv() { + Ok(true) => linked = true, + Ok(false) | Err(oneshot::error::TryRecvError::Closed) => { + plan.next = Some(in_flight.issued + REDIAL); + } + Err(oneshot::error::TryRecvError::Empty) => { + plan.in_flight = Some(in_flight); + } + } + } + if linked { + ended = true; + } else if now >= plan.bound { + ended = true; + unreached = true; + } else if plan.in_flight.is_none() && plan.next.is_some_and(|due| now >= due) { + let (answer, receiver) = oneshot::channel(); + (self.dial)(plan.address, answer); + plan.in_flight = Some(InFlight { + issued: now, + answer: receiver, + }); + plan.next = None; + } + } + if ended { + self.join = JoinState::Joined { dial: None }; + if unreached { + (self.emit)(ClientEvent::JoinedHostUnreached); + } + } + } + + /// Apply the "Client closed" rows, for the loop's end and a dropped + /// client. + pub(crate) fn close(&mut self) { + if matches!( + self.host, + HostState::Starting { .. } | HostState::Hosting { .. } + ) { + self.backend.stop_host(); + } + if matches!( + self.join, + JoinState::Requesting { .. } | JoinState::Joined { .. } + ) { + self.backend.leave_join(); + if matches!(self.join, JoinState::Joined { .. }) { + (self.bind)(None); + } + } + self.host = HostState::Off; + self.join = JoinState::Idle; + } + + /// The loop's end, its channel closed. + async fn run(mut self, mut commands: mpsc::UnboundedReceiver) { + loop { + let (command, tick) = tokio::select! { + command = commands.recv() => (command, false), + () = tokio::time::sleep(TICK) => (None, true), + }; + let closed = command.is_none() && !tick; + if let Some(command) = command { + self.handle(command); + } + if tick { + self.tick(); + } + // A closed channel reports `None` where a tick does too. + if closed { + self.close(); + break; + } + } + } +} + +/// The machine's loop beside the client's pump, ending with it (R76). +pub(crate) struct Runner { + future: Pin + Send>>, + backend: Arc, + bind: SubnetBind, +} + +impl Future for Runner { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<()> { + self.future.as_mut().poll(cx) + } +} + +impl Drop for Runner { + fn drop(&mut self) { + // A dropped client leaves the machine unpollable, so the tables' + // "Client closed" rows apply here, where they are no-ops on the + // backend's own standing. The released network takes the dial bind + // with it. + self.backend.stop_host(); + self.backend.leave_join(); + (self.bind)(None); + } +} + +/// The machine beside the client's pump, the backend's stop and leave +/// applying to a dropped client (R76). +pub(crate) fn run(machine: Machine, commands: mpsc::UnboundedReceiver) -> Runner { + let backend = Arc::clone(&machine.backend); + let bind = Arc::clone(&machine.bind); + Runner { + future: Box::pin(async move { + machine.run(commands).await; + }), + backend, + bind, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A backend whose outcome the test steers, recording its calls. + struct FakeBackend { + host_request: Mutex>, + join_request: Mutex>, + host_status: Mutex, + join_status: Mutex, + calls: Mutex>, + } + + impl FakeBackend { + /// A backend whose requests succeed and whose sides stand pending. + fn new() -> Self { + Self { + host_request: Mutex::new(Ok(())), + join_request: Mutex::new(Ok(())), + host_status: Mutex::new(HostStatus::Pending), + join_status: Mutex::new(JoinStatus::Pending), + calls: Mutex::new(Vec::new()), + } + } + + /// The host side started with `security`, its details ready. + fn started(&self, security: HotspotSecurity) { + *self.host_status.lock() = HostStatus::Started { + ssid: "hotspot".into(), + passphrase: "secret".into(), + security, + }; + } + + /// The join side available on a fixed address and gateway. + fn available(&self) { + *self.join_status.lock() = JoinStatus::Available { + address: "192.168.43.1".parse().unwrap(), + prefix: 24, + gateway: "192.168.43.1".parse().unwrap(), + }; + } + + /// How often the machine made `call` on the backend. + fn counted(&self, call: &'static str) -> usize { + self.calls + .lock() + .iter() + .filter(|recorded| **recorded == call) + .count() + } + } + + impl HotspotBackend for FakeBackend { + fn request_host(&self) -> Result<(), HotspotError> { + self.calls.lock().push("request_host"); + self.host_request.lock().clone() + } + + fn stop_host(&self) { + self.calls.lock().push("stop_host"); + } + + fn host_status(&self) -> HostStatus { + self.host_status.lock().clone() + } + + fn request_join(&self, _offer: &HotspotOffer) -> Result<(), JoinError> { + self.calls.lock().push("request_join"); + self.join_request.lock().clone() + } + + fn leave_join(&self) { + self.calls.lock().push("leave_join"); + } + + fn join_status(&self) -> JoinStatus { + self.join_status.lock().clone() + } + } + + /// The machine over a fresh fake backend, with its seams' records. + /// A recorded subnet bind, the join's gateway and its prefix. + type Subnets = Arc>>>; + + /// The dial seam's answer script, one held dial standing. + struct FakeDial { + /// The answer the next dial gets, at its call. + answer: Mutex>, + /// The dial held back, its answer unsent. + held: Mutex>>, + } + + impl FakeDial { + /// A dial seam with no answer scripted and no dial held. + fn new() -> Self { + Self { + answer: Mutex::new(None), + held: Mutex::new(None), + } + } + + /// The answer the next dial gets, at its call. + fn script(&self, linked: bool) { + *self.answer.lock() = Some(linked); + } + + /// The held dial's answer, for the dial standing unanswered. + fn finish(&self, linked: bool) { + if let Some(answer) = self.held.lock().take() { + let _ = answer.send(linked); + } + } + } + + struct Harness { + machine: Machine, + backend: Arc, + port: Arc>>, + binds: Subnets, + dials: Arc>>, + dial: Arc, + events: Arc>>, + } + + impl Harness { + /// A machine over fresh doubles, linking a joined host on its own + /// when `autolink` holds. + fn new(autolink: bool) -> Self { + let backend: Arc = Arc::new(FakeBackend::new()); + let port: Arc>> = Arc::new(Mutex::new(Some(54321))); + let binds: Subnets = Arc::new(Mutex::new(Vec::new())); + let dials: Arc>> = Arc::new(Mutex::new(Vec::new())); + let dial = Arc::new(FakeDial::new()); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let machine = Machine::new( + Arc::clone(&backend) as Arc, + autolink, + Arc::new({ + let port = Arc::clone(&port); + move || *port.lock() + }), + Arc::new({ + let binds = Arc::clone(&binds); + move |subnet| binds.lock().push(subnet) + }), + Arc::new({ + let dials = Arc::clone(&dials); + let dial = Arc::clone(&dial); + move |addr, answer| { + dials.lock().push(addr); + match dial.answer.lock().take() { + Some(linked) => { + let _ = answer.send(linked); + } + None => *dial.held.lock() = Some(answer), + } + } + }), + Arc::new({ + let events = Arc::clone(&events); + move |event| events.lock().push(event) + }), + ); + Self { + machine, + backend, + port, + binds, + dials, + dial, + events, + } + } + + /// An offer for the doubles' hotspot with `security` and `port`. + fn offer(security: HotspotSecurity, port: Option) -> HotspotOffer { + HotspotOffer::new("hotspot", "secret", security, port) + } + + /// Host through the started row, so the tables stand at Hosting. + fn hosting(&mut self) { + let (reply, mut answer) = oneshot::channel(); + self.machine.handle(Command::Host(reply)); + self.backend.started(HotspotSecurity::Wpa2); + self.machine.tick(); + assert_eq!( + answer.try_recv().unwrap(), + Ok(Self::offer(HotspotSecurity::Wpa2, Some(54321))) + ); + } + + /// Join through the available row, so the tables stand at Joined. + fn joined(&mut self) { + let (reply, mut answer) = oneshot::channel(); + self.machine.handle(Command::Join { + offer: Self::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + self.backend.available(); + self.machine.tick(); + let gateway: IpAddr = "192.168.43.1".parse().unwrap(); + assert_eq!(answer.try_recv().unwrap(), Ok(gateway)); + } + } + + /// The host row `host_hotspot | Off | a missing permission answers + /// MissingPermission`, and the next request still goes. + #[test] + fn a_missing_permission_answers_the_host_request() { + let mut h = Harness::new(true); + *h.backend.host_request.lock() = Err(HotspotError::MissingPermission( + "NEARBY_WIFI_DEVICES".into(), + )); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + assert_eq!( + answer.try_recv().unwrap(), + Err(HotspotError::MissingPermission( + "NEARBY_WIFI_DEVICES".into() + )) + ); + *h.backend.host_request.lock() = Ok(()); + let (fresh, mut fresh_answer) = oneshot::channel(); + h.machine.handle(Command::Host(fresh)); + assert_eq!(h.backend.counted("request_host"), 2); + assert!(fresh_answer.try_recv().is_err()); + } + + /// The host rows `host_hotspot | Off | request the network` and + /// `host_hotspot | Starting | wait on the same request`. + #[test] + fn a_host_request_waits_on_its_start() { + let mut h = Harness::new(true); + let (first, mut first_answer) = oneshot::channel(); + h.machine.handle(Command::Host(first)); + let (second, mut second_answer) = oneshot::channel(); + h.machine.handle(Command::Host(second)); + assert_eq!(h.backend.counted("request_host"), 1); + h.backend.started(HotspotSecurity::Wpa3); + h.machine.tick(); + let offer = Harness::offer(HotspotSecurity::Wpa3, Some(54321)); + assert_eq!(first_answer.try_recv().unwrap(), Ok(offer.clone())); + assert_eq!(second_answer.try_recv().unwrap(), Ok(offer)); + } + + /// The host rows `Hotspot started | Starting | become Hosting` and the + /// port present while the device serves. + #[test] + fn a_started_hotspot_hosts_with_its_offer() { + let mut h = Harness::new(true); + h.hosting(); + assert!(h.events.lock().is_empty()); + assert!(h.binds.lock().is_empty()); + } + + /// The host row `host_hotspot | Hosting | answer the current offer`, + /// with the port as the standing has it. + #[test] + fn a_hosting_host_answers_the_current_offer() { + let mut h = Harness::new(true); + h.hosting(); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + assert_eq!( + answer.try_recv().unwrap(), + Ok(Harness::offer(HotspotSecurity::Wpa2, Some(54321))) + ); + // The standing loses the listener, and the port follows it. + *h.port.lock() = None; + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + assert_eq!( + answer.try_recv().unwrap(), + Ok(Harness::offer(HotspotSecurity::Wpa2, None)) + ); + } + + /// The host row `Hotspot failed | Starting | Off, answer the mapped + /// error`, and the next request still goes. + #[test] + fn a_failed_hotspot_answers_its_mapped_error() { + let mut h = Harness::new(true); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + *h.backend.host_status.lock() = HostStatus::Failed(HotspotError::NoChannel); + h.machine.tick(); + assert_eq!(answer.try_recv().unwrap(), Err(HotspotError::NoChannel)); + let (fresh, mut fresh_answer) = oneshot::channel(); + h.machine.handle(Command::Host(fresh)); + assert_eq!(h.backend.counted("request_host"), 2); + assert!(fresh_answer.try_recv().is_err()); + } + + /// The host row `30 s without an answer | Starting | cancel, Off, + /// answer TimedOut`. + #[tokio::test] + async fn the_host_bound_cancels_a_request_without_an_answer() { + tokio::time::pause(); + let mut h = Harness::new(true); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + tokio::time::advance(HOST_BOUND).await; + h.machine.tick(); + assert_eq!(answer.try_recv().unwrap(), Err(HotspotError::TimedOut)); + assert_eq!(h.backend.counted("stop_host"), 1); + } + + /// The host row `System or user stops it | Starting | Off, answer + /// Failed`, the backend already stopped, so nothing cancels. + #[test] + fn a_system_stop_cancels_a_starting_host() { + let mut h = Harness::new(true); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + *h.backend.host_status.lock() = HostStatus::Stopped; + h.machine.tick(); + assert_eq!(answer.try_recv().unwrap(), Err(HotspotError::Failed)); + assert_eq!(h.backend.counted("stop_host"), 0); + } + + /// The host rows `System or user stops it | Hosting | Off, raise + /// HotspotStopped` and `stop_hotspot | Hosting | stop the reservation`, + /// with no event for the user's stop. + #[test] + fn a_stopped_hotspot_leaves_its_host_with_its_event() { + let mut h = Harness::new(true); + h.hosting(); + *h.backend.host_status.lock() = HostStatus::Stopped; + h.machine.tick(); + assert_eq!(h.events.lock().as_slice(), &[ClientEvent::HotspotStopped]); + assert_eq!(h.backend.counted("stop_host"), 0); + } + + #[test] + fn a_user_stop_closes_a_hosting_host_without_its_event() { + let mut h = Harness::new(true); + h.hosting(); + h.machine.handle(Command::StopHost); + assert_eq!(h.backend.counted("stop_host"), 1); + assert!(h.events.lock().is_empty()); + } + + /// The host rows `stop_hotspot | Starting | cancel, answer the pending + /// call Failed` and `stop_hotspot | Off | nothing`. + #[test] + fn a_user_stop_cancels_a_starting_host() { + let mut h = Harness::new(true); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + h.machine.handle(Command::StopHost); + assert_eq!(h.backend.counted("stop_host"), 1); + assert_eq!(answer.try_recv().unwrap(), Err(HotspotError::Failed)); + h.machine.handle(Command::StopHost); + assert_eq!(h.backend.counted("stop_host"), 1); + } + + /// The host rows `Client closed`, across the three standings. + #[test] + fn a_closed_client_applies_its_host_rows() { + let mut h = Harness::new(true); + h.machine.close(); + assert_eq!(h.backend.counted("stop_host"), 0); + + let (reply, _answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + h.machine.close(); + assert_eq!(h.backend.counted("stop_host"), 1); + + let (reply, _answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + h.backend.started(HotspotSecurity::Wpa2); + h.machine.tick(); + h.machine.close(); + assert_eq!(h.backend.counted("stop_host"), 2); + } + + /// The join row `join_hotspot | Idle | a missing permission answers + /// MissingPermission`, and the next request still goes. + #[test] + fn a_missing_permission_answers_the_join_request() { + let mut h = Harness::new(true); + *h.backend.join_request.lock() = + Err(JoinError::MissingPermission("NEARBY_WIFI_DEVICES".into())); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + assert_eq!( + answer.try_recv().unwrap(), + Err(JoinError::MissingPermission("NEARBY_WIFI_DEVICES".into())) + ); + *h.backend.join_request.lock() = Ok(()); + let (fresh, mut fresh_answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply: fresh, + }); + assert_eq!(h.backend.counted("request_join"), 2); + assert!(fresh_answer.try_recv().is_err()); + } + + /// The join row `join_hotspot | Requesting | cancel the earlier request, + /// request this one`, the earlier release standing before the new + /// request. + #[test] + fn a_join_cancels_the_earlier_request() { + let mut h = Harness::new(true); + let (first, mut first_answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply: first, + }); + let (second, mut second_answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply: second, + }); + assert_eq!( + h.backend.calls.lock().as_slice(), + &["request_join", "leave_join", "request_join"], + "the earlier request stands released before the new one goes" + ); + assert_eq!(first_answer.try_recv().unwrap(), Err(JoinError::Declined)); + assert!(second_answer.try_recv().is_err()); + } + + /// The join row `Network available | Requesting | become Joined`, with + /// the bind, the gateway answer and the autolink dial. + #[test] + fn an_available_network_joins_binds_and_answers() { + let mut h = Harness::new(true); + h.joined(); + let gateway: IpAddr = "192.168.43.1".parse().unwrap(); + assert_eq!( + h.binds.lock().as_slice(), + &[Some(("192.168.43.1".parse().unwrap(), 24))] + ); + assert_eq!( + h.dials.lock().as_slice(), + &[SocketAddr::new(gateway, 54321)] + ); + } + + /// The autolink dial, with the autolink off and with the offer's port + /// absent. + #[test] + fn the_autolink_dial_follows_its_rows() { + let mut h = Harness::new(false); + h.joined(); + assert!(h.dials.lock().is_empty()); + + let mut h = Harness::new(true); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, None), + reply, + }); + h.backend.available(); + h.machine.tick(); + assert!(answer.try_recv().unwrap().is_ok()); + assert!(h.dials.lock().is_empty()); + } + + /// The join row `Network unavailable | Requesting | Idle, answer + /// Declined`. + #[test] + fn an_unavailable_network_declines() { + let mut h = Harness::new(true); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + *h.backend.join_status.lock() = JoinStatus::Unavailable; + h.machine.tick(); + assert_eq!(answer.try_recv().unwrap(), Err(JoinError::Declined)); + } + + /// The join row `60 s without an answer | Requesting | cancel, Idle, + /// answer TimedOut`. + #[tokio::test] + async fn the_join_bound_cancels_a_request_without_an_answer() { + tokio::time::pause(); + let mut h = Harness::new(true); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + tokio::time::advance(JOIN_BOUND).await; + h.machine.tick(); + assert_eq!(answer.try_recv().unwrap(), Err(JoinError::TimedOut)); + assert_eq!(h.backend.counted("leave_join"), 1); + } + + /// The join row `Network lost | Joined | stop binding, Idle, raise + /// HotspotLeft`. + #[test] + fn a_lost_network_leaves_its_join_with_its_event() { + let mut h = Harness::new(true); + h.joined(); + *h.backend.join_status.lock() = JoinStatus::Lost; + h.machine.tick(); + assert_eq!(h.events.lock().as_slice(), &[ClientEvent::HotspotLeft]); + assert_eq!(*h.binds.lock().last().unwrap(), None); + assert_eq!(h.backend.counted("leave_join"), 0); + } + + /// The join row `The dial to the gateway fails | Joined | dial again 2 + /// seconds later while no link to the gateway stands`, no sooner. + #[tokio::test] + async fn a_failed_dial_redials_after_its_wait() { + tokio::time::pause(); + let mut h = Harness::new(true); + h.dial.script(false); + h.joined(); + h.machine.tick(); + tokio::time::advance(Duration::from_secs(1)).await; + h.machine.tick(); + tokio::time::advance(Duration::from_millis(900)).await; + h.machine.tick(); + assert_eq!(h.dials.lock().len(), 1); + tokio::time::advance(Duration::from_millis(100)).await; + h.machine.tick(); + assert_eq!(h.dials.lock().len(), 2); + } + + /// The join row `A link to the gateway stands | Joined | stop dialing`. + #[tokio::test] + async fn a_linked_dial_stops_the_redial() { + tokio::time::pause(); + let mut h = Harness::new(true); + h.dial.script(false); + h.joined(); + h.machine.tick(); + tokio::time::advance(REDIAL).await; + h.dial.script(true); + h.machine.tick(); + h.machine.tick(); + tokio::time::advance(DIAL_BOUND).await; + h.machine.tick(); + assert_eq!(h.dials.lock().len(), 2); + assert!(h.events.lock().is_empty()); + } + + /// The join row `The dial to the gateway fails | Joined | 60 seconds + /// after the join stop and emit JoinedHostUnreached`, once. + #[tokio::test] + async fn the_dial_bound_emits_its_unreached_once() { + tokio::time::pause(); + let mut h = Harness::new(true); + h.dial.script(false); + h.joined(); + for _ in 0..31 { + h.dial.script(false); + tokio::time::advance(REDIAL).await; + h.machine.tick(); + } + assert_eq!(h.dials.lock().len(), 30); + assert_eq!( + h.events.lock().as_slice(), + &[ClientEvent::JoinedHostUnreached] + ); + tokio::time::advance(DIAL_BOUND).await; + h.machine.tick(); + assert_eq!(h.dials.lock().len(), 30); + assert_eq!( + h.events.lock().as_slice(), + &[ClientEvent::JoinedHostUnreached] + ); + } + + /// The dial still running when the bound passes, its answer ignored + /// after it links. + #[tokio::test] + async fn a_running_dial_lets_its_answer_pass_the_bound() { + tokio::time::pause(); + let mut h = Harness::new(true); + h.joined(); + tokio::time::advance(DIAL_BOUND).await; + h.machine.tick(); + assert_eq!( + h.events.lock().as_slice(), + &[ClientEvent::JoinedHostUnreached] + ); + h.dial.finish(true); + tokio::time::advance(REDIAL).await; + h.machine.tick(); + assert_eq!(h.dials.lock().len(), 1); + assert_eq!( + h.events.lock().as_slice(), + &[ClientEvent::JoinedHostUnreached] + ); + } + + /// The join row `Network lost | Joined | stop dialing`, the redial + /// over. + #[tokio::test] + async fn a_lost_network_stops_the_redial() { + tokio::time::pause(); + let mut h = Harness::new(true); + h.dial.script(false); + h.joined(); + h.machine.tick(); + *h.backend.join_status.lock() = JoinStatus::Lost; + h.machine.tick(); + assert_eq!(h.events.lock().as_slice(), &[ClientEvent::HotspotLeft]); + tokio::time::advance(DIAL_BOUND).await; + h.machine.tick(); + assert_eq!(h.dials.lock().len(), 1); + assert_eq!(h.events.lock().as_slice(), &[ClientEvent::HotspotLeft]); + } + + /// The join row `leave_hotspot | Joined | stop dialing`, the redial + /// over. + #[tokio::test] + async fn a_user_leave_stops_the_redial() { + tokio::time::pause(); + let mut h = Harness::new(true); + h.dial.script(false); + h.joined(); + h.machine.tick(); + h.machine.handle(Command::Leave); + tokio::time::advance(DIAL_BOUND).await; + h.machine.tick(); + assert_eq!(h.dials.lock().len(), 1); + assert!(h.events.lock().is_empty()); + assert_eq!(h.backend.counted("leave_join"), 1); + } + + /// The join rows `leave_hotspot | Idle | nothing` and `| Requesting | + /// cancel, answer the pending call Declined`. + #[test] + fn a_user_leave_cancels_a_requesting_join() { + let mut h = Harness::new(true); + h.machine.handle(Command::Leave); + assert!(h.backend.calls.lock().is_empty()); + + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + h.machine.handle(Command::Leave); + assert_eq!(h.backend.counted("leave_join"), 1); + assert_eq!(answer.try_recv().unwrap(), Err(JoinError::Declined)); + } + + /// The join row `leave_hotspot | Joined | release the network, stop + /// binding`, with no event. + #[test] + fn a_user_leave_releases_a_joined_join_without_its_event() { + let mut h = Harness::new(true); + h.joined(); + h.machine.handle(Command::Leave); + assert_eq!(h.backend.counted("leave_join"), 1); + assert_eq!(*h.binds.lock().last().unwrap(), None); + assert!(h.events.lock().is_empty()); + } + + /// The join rows `Client closed`, across the three standings. + #[test] + fn a_closed_client_applies_its_join_rows() { + let mut h = Harness::new(true); + h.machine.close(); + assert!(h.backend.calls.lock().is_empty()); + + let (reply, _answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + h.machine.close(); + assert_eq!(h.backend.counted("leave_join"), 1); + + let (reply, _answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + h.backend.available(); + h.machine.tick(); + h.machine.close(); + assert_eq!(h.backend.counted("leave_join"), 2); + assert_eq!(*h.binds.lock().last().unwrap(), None); + } + + /// The join row `join_hotspot | Joined | release the joined network, + /// request this one`. + #[test] + fn a_join_during_joined_releases_the_earlier_network() { + let mut h = Harness::new(true); + h.joined(); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply, + }); + assert_eq!( + h.backend.calls.lock().as_slice(), + &["request_join", "leave_join", "request_join"], + "the network stands released before the new request goes" + ); + assert_eq!(*h.binds.lock().last().unwrap(), None); + assert!(answer.try_recv().is_err()); + } + + /// The join row `join_hotspot | Requesting | cancel the earlier request, + /// request this one`, with a request that refuses at once. The earlier + /// release still stands before it, the error answers, and the side + /// stands idle. + #[test] + fn a_refused_join_request_leaves_the_side_idle() { + let mut h = Harness::new(true); + let (first, mut first_answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply: first, + }); + *h.backend.join_request.lock() = + Err(JoinError::MissingPermission("NEARBY_WIFI_DEVICES".into())); + let (second, mut second_answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply: second, + }); + assert_eq!( + h.backend.calls.lock().as_slice(), + &["request_join", "leave_join", "request_join"], + "the earlier request stands released before the refused one" + ); + assert_eq!(first_answer.try_recv().unwrap(), Err(JoinError::Declined)); + assert_eq!( + second_answer.try_recv().unwrap(), + Err(JoinError::MissingPermission("NEARBY_WIFI_DEVICES".into())) + ); + // The side stands idle, so a good request still goes to the tables. + *h.backend.join_request.lock() = Ok(()); + let (third, mut third_answer) = oneshot::channel(); + h.machine.handle(Command::Join { + offer: Harness::offer(HotspotSecurity::Wpa2, Some(54321)), + reply: third, + }); + assert!( + third_answer.try_recv().is_err(), + "the good request is in flight" + ); + } + + /// The runner answers a command through its channel and ends with it. + #[tokio::test] + async fn the_runner_answers_a_host_and_ends_with_its_channel() { + tokio::time::pause(); + let backend: Arc = Arc::new(FakeBackend::new()); + let machine = Machine::new( + Arc::clone(&backend) as Arc, + true, + Arc::new(|| Some(54321)), + Arc::new(|_| {}), + Arc::new(|_, _| {}), + Arc::new(|_| {}), + ); + let (tx, rx) = mpsc::unbounded_channel(); + let runner = run(machine, rx); + let joined = tokio::spawn(async move { + runner.await; + }); + let (reply, mut answer) = oneshot::channel(); + tx.send(Command::Host(reply)).unwrap(); + // The loop's ticks, with the started outcome between them. + for _ in 0..8 { + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + } + backend.started(HotspotSecurity::Wpa2); + for _ in 0..8 { + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + } + // The answer was sent by then, so the take gets it at once. + let answer = answer.try_recv().unwrap(); + assert_eq!( + answer, + Ok(HotspotOffer::new( + "hotspot", + "secret", + HotspotSecurity::Wpa2, + Some(54321) + )) + ); + drop(tx); + for _ in 0..8 { + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + } + tokio::time::timeout(Duration::from_millis(1000), joined) + .await + .expect("the loop ends with its channel") + .expect("the loop task ends"); + } + + /// A dropped runner releases both sides and clears the dial bind, so a + /// dial into the joined subnet no longer asks for the released network. + #[test] + fn a_dropped_runner_releases_its_sides_and_clears_the_bind() { + let backend: Arc = Arc::new(FakeBackend::new()); + let binds: Subnets = Arc::new(Mutex::new(Vec::new())); + let machine = Machine::new( + Arc::clone(&backend) as Arc, + true, + Arc::new(|| Some(54321)), + Arc::new({ + let binds = Arc::clone(&binds); + move |subnet| binds.lock().push(subnet) + }), + Arc::new(|_, _| {}), + Arc::new(|_| {}), + ); + let (_tx, rx) = mpsc::unbounded_channel(); + drop(run(machine, rx)); + assert_eq!(backend.counted("stop_host"), 1); + assert_eq!(backend.counted("leave_join"), 1); + assert_eq!(*binds.lock(), vec![None]); + } + + /// The offer's debug form hides its passphrase. + #[test] + fn the_offer_hides_its_passphrase_in_its_debug_form() { + let offer = HotspotOffer::new("net", "hunter2", HotspotSecurity::Wpa3, Some(443)); + let text = format!("{offer:?}"); + assert!(!text.contains("hunter2")); + assert!(text.contains("net")); + assert!(text.contains("")); + } +} diff --git a/crates/connetto-client/src/hotspot/android.rs b/crates/connetto-client/src/hotspot/android.rs new file mode 100644 index 00000000..f1383f10 --- /dev/null +++ b/crates/connetto-client/src/hotspot/android.rs @@ -0,0 +1,404 @@ +//! The Android hotspot backend, over the application's JNI access (R76). +//! +//! The backend drives the bundled `HotspotPlugin` through the device's +//! `WifiManager` and `ConnectivityManager`, and the joined network's subnet +//! reaches the dials' sockets through the plugin's `bindSocket`. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::os::fd::{AsFd, AsRawFd}; +use std::sync::Arc; + +use jni::JNIEnv; +use jni::objects::{JClass, JString, JValue}; +use socket2::SockRef; + +use super::{ + HostStatus, HotspotBackend, HotspotError, HotspotOffer, HotspotSecurity, JoinError, JoinStatus, +}; +use crate::device_key::JavaAccess; + +/// The plugin the backend drives, bundled by the app's `dx` build. +const PLUGIN_CLASS: &str = "dev.connetto.peer.HotspotPlugin"; + +/// The plugin's security codes, `WifiNetworkSpecifier`'s choice. +const SECURITY_WPA2: i32 = 0; +const SECURITY_WPA3: i32 = 1; + +/// The plugin's host outcome codes. +const HOST_STARTED: i32 = 2; +const HOST_FAILED: i32 = 3; +const HOST_STOPPED: i32 = 4; + +/// The plugin's join outcome codes. +const JOIN_AVAILABLE: i32 = 2; +const JOIN_UNAVAILABLE: i32 = 3; +const JOIN_LOST: i32 = 4; + +/// The permissions whose names a failure's text names, the backstop to the +/// missing-permission pre-check. +const KNOWN_PERMISSIONS: [&str; 2] = ["NEARBY_WIFI_DEVICES", "CHANGE_WIFI_MULTICAST_STATE"]; + +/// The join's timeout in milliseconds, the join bound handed to the device. +const JOIN_TIMEOUT_MS: i64 = 60_000; + +/// The device's SDK level, from the build's properties. +fn sdk() -> u32 { + android_system_properties::AndroidSystemProperties::new() + .get("ro.build.version.sdk") + .and_then(|sdk| sdk.parse().ok()) + .unwrap_or(0) +} + +/// A failure reaching the plugin through the Java VM. +#[derive(Debug, thiserror::Error)] +enum PluginFailure { + /// A refusal from the Java side, kept as the exception's text, else the JNI error's. + #[error("the hotspot plugin refused: {0}")] + Java(String), + /// The process has no reachable Java VM. + #[error("the hotspot plugin will not run: {0}")] + NoVm(String), + /// The application's JNI access returned without running the body. + #[error("the hotspot plugin will not run: the JNI access ran nothing")] + RanNothing, +} + +/// The permission a failure's text names, else nothing. +fn permission_in(text: &str) -> Option { + KNOWN_PERMISSIONS + .into_iter() + .find(|name| text.contains(name)) + .map(ToOwned::to_owned) +} + +/// Map a plugin failure to the host side's error. +fn host_failure(failure: PluginFailure) -> HotspotError { + match failure { + PluginFailure::Java(text) => { + permission_in(&text).map_or(HotspotError::Failed, HotspotError::MissingPermission) + } + PluginFailure::NoVm(_) | PluginFailure::RanNothing => HotspotError::Failed, + } +} + +/// Map a plugin failure to the join side's error. +fn join_failure(failure: PluginFailure) -> JoinError { + match failure { + PluginFailure::Java(text) => { + permission_in(&text).map_or(JoinError::Failed, JoinError::MissingPermission) + } + PluginFailure::NoVm(_) | PluginFailure::RanNothing => JoinError::Failed, + } +} + +/// The pending Java exception's text, cleared, else the JNI error's. +fn exception_text(env: &mut JNIEnv<'_>, err: &jni::errors::Error) -> PluginFailure { + let pending = env.exception_occurred().ok().filter(|ex| !ex.is_null()); + let _ = env.exception_clear(); + let text = pending + .and_then(|ex| { + env.call_method(&ex, "toString", "()Ljava/lang/String;", &[]) + .ok() + }) + .and_then(|value| value.l().ok()) + .and_then(|text| env.get_string(&text.into()).ok().map(String::from)); + PluginFailure::Java(text.unwrap_or_else(|| err.to_string())) +} + +/// Run `body` on the device's Java VM, a pending exception becoming its text. +fn call( + java: &dyn JavaAccess, + body: impl FnOnce(&mut JNIEnv<'_>) -> jni::errors::Result, +) -> Result { + let mut body = Some(body); + let mut outcome: Option> = None; + java.with_env(&mut |env| { + let Some(body) = body.take() else { return }; + outcome = Some(body(env).map_err(|err| exception_text(env, &err))); + }) + .map_err(|err| PluginFailure::NoVm(err.to_string()))?; + match outcome { + Some(outcome) => outcome, + None => Err(PluginFailure::RanNothing), + } +} + +/// The plugin's class, on the application's class loader. +fn plugin<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result> { + let application = env + .call_static_method( + "android/app/ActivityThread", + "currentApplication", + "()Landroid/app/Application;", + &[], + )? + .l()?; + let class_loader = env + .call_method( + &application, + "getClassLoader", + "()Ljava/lang/ClassLoader;", + &[], + )? + .l()?; + let name = env.new_string(PLUGIN_CLASS)?; + let class = env + .call_method( + &class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValue::Object(&name)], + )? + .l()?; + Ok(JClass::from(class)) +} + +/// A plugin `()I` result. +fn int(env: &mut JNIEnv<'_>, class: &JClass<'_>, name: &str) -> jni::errors::Result { + env.call_static_method(class, name, "()I", &[])?.i() +} + +/// A plugin `()Ljava/lang/String;` result, `None` where it is unset. +fn string( + env: &mut JNIEnv<'_>, + class: &JClass<'_>, + name: &str, +) -> jni::errors::Result> { + let value = env + .call_static_method(class, name, "()Ljava/lang/String;", &[])? + .l()?; + if value.is_null() { + return Ok(None); + } + Ok(Some(env.get_string(&JString::from(value))?.into())) +} + +/// The permissions the device has not granted, the plugin's joined list. +fn missing_permissions( + env: &mut JNIEnv<'_>, + class: &JClass<'_>, +) -> jni::errors::Result> { + let Some(text) = string(env, class, "missingPermissions")? else { + return Ok(Vec::new()); + }; + Ok(text.lines().map(str::to_string).collect()) +} + +/// The Android backend, over the application's JNI access (R76). +pub(crate) struct AndroidHotspotBackend { + java: Arc, +} + +impl AndroidHotspotBackend { + /// The backend over `java`. + pub(crate) fn new(java: Arc) -> Self { + Self { java } + } +} + +impl HotspotBackend for AndroidHotspotBackend { + fn request_host(&self) -> Result<(), HotspotError> { + // The host's credentials come from the started callback, API 33+. + if sdk() < 33 { + return Err(HotspotError::Unsupported); + } + let missing = call(&*self.java, |env| { + let class = plugin(env)?; + missing_permissions(env, &class) + }) + .map_err(host_failure)?; + if let Some(name) = missing.into_iter().next() { + return Err(HotspotError::MissingPermission(name)); + } + call(&*self.java, |env| { + let class = plugin(env)?; + env.call_static_method(class, "startHost", "()V", &[])?; + Ok(()) + }) + .map_err(host_failure) + } + + fn stop_host(&self) { + let _ = call(&*self.java, |env| { + let class = plugin(env)?; + env.call_static_method(class, "stopHost", "()V", &[])?; + Ok(()) + }); + } + + fn host_status(&self) -> HostStatus { + let outcome = call(&*self.java, |env| { + let class = plugin(env)?; + Ok(( + int(env, &class, "hostState")?, + string(env, &class, "hostSsid")?, + string(env, &class, "hostPassphrase")?, + int(env, &class, "hostSecurity")?, + string(env, &class, "hostFailure")?, + )) + }); + // The VM is unreachable, so the standing stands as it was. + let Ok((state, ssid, passphrase, security, failure)) = outcome else { + return HostStatus::Pending; + }; + match state { + HOST_STARTED => { + let security = if security == SECURITY_WPA3 { + HotspotSecurity::Wpa3 + } else { + HotspotSecurity::Wpa2 + }; + match (ssid, passphrase) { + (Some(ssid), Some(passphrase)) => HostStatus::Started { + ssid, + passphrase, + security, + }, + _ => HostStatus::Pending, + } + } + HOST_FAILED => { + let permission = failure.as_ref().and_then(|text| permission_in(text)); + let err = match permission { + Some(name) => HotspotError::MissingPermission(name), + None => match failure.as_deref() { + // The plugin's failure codes, `WifiManager`'s. + Some("1") => HotspotError::Incompatible, + Some("2") => HotspotError::NoChannel, + _ => HotspotError::Failed, + }, + }; + HostStatus::Failed(err) + } + HOST_STOPPED => HostStatus::Stopped, + // The plugin's idle and pending standings. + _ => HostStatus::Pending, + } + } + + fn request_join(&self, offer: &HotspotOffer) -> Result<(), JoinError> { + if sdk() < 29 { + return Err(JoinError::Unsupported); + } + let missing = call(&*self.java, |env| { + let class = plugin(env)?; + missing_permissions(env, &class) + }) + .map_err(join_failure)?; + if let Some(name) = missing.into_iter().next() { + return Err(JoinError::MissingPermission(name)); + } + let security = if offer.security == HotspotSecurity::Wpa3 { + SECURITY_WPA3 + } else { + SECURITY_WPA2 + }; + call(&*self.java, |env| { + let class = plugin(env)?; + let ssid = env.new_string(&offer.ssid)?; + let passphrase = env.new_string(&offer.passphrase)?; + env.call_static_method( + class, + "join", + "(Ljava/lang/String;Ljava/lang/String;IJ)V", + &[ + JValue::Object(&ssid), + JValue::Object(&passphrase), + JValue::Int(security), + JValue::Long(JOIN_TIMEOUT_MS), + ], + )?; + Ok(()) + }) + .map_err(join_failure) + } + + fn leave_join(&self) { + let _ = call(&*self.java, |env| { + let class = plugin(env)?; + env.call_static_method(class, "leave", "()V", &[])?; + Ok(()) + }); + } + + fn join_status(&self) -> JoinStatus { + let outcome = call(&*self.java, |env| { + let class = plugin(env)?; + Ok(( + int(env, &class, "joinState")?, + string(env, &class, "joinAddress")?, + int(env, &class, "joinPrefix")?, + string(env, &class, "joinGateway")?, + )) + }); + // The VM is unreachable, so the standing stands as it was. + let Ok((state, address, prefix, gateway)) = outcome else { + return JoinStatus::Pending; + }; + match state { + JOIN_AVAILABLE => { + let address = address.and_then(|address| address.parse().ok()); + let gateway = gateway.and_then(|gateway| gateway.parse().ok()); + let prefix = match u8::try_from(prefix) { + Ok(prefix) if prefix <= 32 => prefix, + _ => return JoinStatus::Pending, + }; + match (address, gateway) { + (Some(address), Some(gateway)) => JoinStatus::Available { + address, + prefix, + gateway, + }, + _ => JoinStatus::Pending, + } + } + JOIN_UNAVAILABLE => JoinStatus::Unavailable, + JOIN_LOST => JoinStatus::Lost, + // The plugin's idle and pending standings. + _ => JoinStatus::Pending, + } + } +} + +/// The dials' bind to the joined network, for the targets it lies in (R76). +pub(crate) struct JoinedBind { + java: Arc, + /// The joined network's subnet, its address and prefix length. + subnet: (Ipv4Addr, u8), +} + +impl JoinedBind { + /// The bind over `java`, for `subnet`. + pub(crate) fn new(java: Arc, subnet: (Ipv4Addr, u8)) -> Self { + Self { java, subnet } + } +} + +impl connetto_peer::SocketPrep for JoinedBind { + fn prepare(&self, sock: SockRef<'_>, target: SocketAddr) -> std::io::Result<()> { + let SocketAddr::V4(target) = target else { + return Ok(()); + }; + let Ok(net) = ipnet::Ipv4Net::new(self.subnet.0, self.subnet.1) else { + return Ok(()); + }; + if !net.contains(target.ip()) { + return Ok(()); + } + let fd = sock.as_fd().as_raw_fd(); + let bound = call(&*self.java, |env| { + let class = plugin(env)?; + let bound = env + .call_static_method(class, "bindSocket", "(I)Z", &[JValue::from(fd)])? + .z()?; + Ok(bound) + }) + .map_err(|failure| std::io::Error::other(failure.to_string()))?; + if !bound { + return Err(std::io::Error::other( + "the joined network will not take the dial's socket", + )); + } + Ok(()) + } +} diff --git a/crates/connetto-client/src/lib.rs b/crates/connetto-client/src/lib.rs index 5dc57ad4..ddf8c3ec 100644 --- a/crates/connetto-client/src/lib.rs +++ b/crates/connetto-client/src/lib.rs @@ -89,6 +89,8 @@ pub mod dsl; mod enrolment; mod grant_expiry; pub mod harden; +#[cfg(feature = "peer")] +mod hotspot; #[cfg(feature = "native-auth")] mod keyring; pub mod live; @@ -101,6 +103,8 @@ mod subscriptions; #[cfg(feature = "device-identity")] pub use enrolment::{CertificateError, DeviceEntry}; +#[cfg(feature = "peer")] +pub use hotspot::{HotspotError, HotspotOffer, HotspotSecurity, JoinError}; pub use subscriptions::{DEFAULT_GRACE, MAX_GRACE}; pub mod teardown; @@ -1263,6 +1267,16 @@ pub enum ClientEvent { /// The instance's leaf fingerprint. fingerprint: connetto_peer::Fingerprint, }, + /// The device's hotspot stopped hosting, by the system or the user (R76). + #[cfg(feature = "peer")] + HotspotStopped, + /// The device left its joined hotspot (R76). + #[cfg(feature = "peer")] + HotspotLeft, + /// The redial to its joined hotspot's gateway passed its bound with no + /// link standing, so it stopped (R76). + #[cfg(feature = "peer")] + JoinedHostUnreached, } /// A primary-key column value carried on a mutation event. diff --git a/crates/connetto-client/src/live.rs b/crates/connetto-client/src/live.rs index 9c365776..ff505695 100644 --- a/crates/connetto-client/src/live.rs +++ b/crates/connetto-client/src/live.rs @@ -2550,6 +2550,12 @@ where self.shared.events.subscribe() } + /// The client's event stream the background tasks publish into (R76). + #[cfg(feature = "peer")] + pub(crate) fn event_sender(&self) -> broadcast::Sender { + self.shared.events.clone() + } + /// Postfix-free spelling of the [`Watchable`](crate::dsl::Watchable) /// verb: `client.live(query)` is `query.live(&client)`, with the handle /// type chosen at compile time from the query's shape. The `R` parameter diff --git a/crates/connetto-client/tests/it/enrolment.rs b/crates/connetto-client/tests/it/enrolment.rs index 6672b291..414d9b73 100644 --- a/crates/connetto-client/tests/it/enrolment.rs +++ b/crates/connetto-client/tests/it/enrolment.rs @@ -1745,6 +1745,7 @@ async fn peer_report() { dir_a.path(), ) .with_peer_listener(listener) + .with_peer_autolink(false) .connect() .await .expect("a connects"); @@ -1754,6 +1755,7 @@ async fn peer_report() { dir_b.path(), ) .with_peer_listener(listener) + .with_peer_autolink(false) .connect() .await .expect("b connects"); @@ -1763,6 +1765,7 @@ async fn peer_report() { dir_c.path(), ) .with_peer_listener(listener) + .with_peer_autolink(false) .connect() .await .expect("c connects"); @@ -1791,6 +1794,7 @@ async fn peer_report() { // B signs back in silently, with no server, and links a. let (b2, b2_pump) = signed_in(addr, last_used(&base, APP_PEER_B), dir_b.path()) .with_peer_listener(listener) + .with_peer_autolink(false) .connect_with_pump() .await .expect("b reopens"); @@ -1839,6 +1843,7 @@ async fn peer_report() { // refuses it, since b learned c's revocation from a. let (c2, c2_pump) = signed_in(addr, last_used(&base, APP_PEER_C), dir_c.path()) .with_peer_listener(listener) + .with_peer_autolink(false) .connect_with_pump() .await .expect("c reopens"); diff --git a/crates/connetto-peer-android/Cargo.toml b/crates/connetto-peer-android/Cargo.toml new file mode 100644 index 00000000..85c06315 --- /dev/null +++ b/crates/connetto-peer-android/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "connetto-peer-android" +description = "The Android permissions the connetto peer link asks for, and the bundled Kotlin module that hosts and joins the local-only hotspot behind the peer link." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +thiserror.workspace = true + +[target.'cfg(target_os = "android")'.dependencies] +# `ffi` builds and bundles the Kotlin module this crate names, and +# `android::with_activity` reaches the Activity, without code of our own +# touching raw JNI pointers. +manganis = "0.7.10" diff --git a/crates/connetto-peer-android/README.md b/crates/connetto-peer-android/README.md new file mode 100644 index 00000000..b19e9727 --- /dev/null +++ b/crates/connetto-peer-android/README.md @@ -0,0 +1,15 @@ +# The peer link's Android permissions + +[![Tests](https://github.com/LucaCappelletti94/connetto-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/LucaCappelletti94/connetto-rs/actions) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/LucaCappelletti94/connetto-rs/blob/main/LICENSE) +[![Coverage](https://codecov.io/gh/LucaCappelletti94/connetto-rs/graph/badge.svg)](https://codecov.io/gh/LucaCappelletti94/connetto-rs) + +The peer link needs two Android permissions: `CHANGE_WIFI_MULTICAST_STATE`, for the mDNS discovery that finds a peer's link, and `NEARBY_WIFI_DEVICES` (API 33 and later), for hosting and joining the local-only hotspot a peer dials through. This crate asks the operating system for whichever is missing and bundles the Kotlin module the rest of the peer link drives over JNI. + +`request_peer_permissions` opens the prompt on the Activity, so it runs on the UI thread only. Off Android it does nothing. + +```rust +// Off Android the prompt asks nothing, where the peer link's +// permissions do not exist. +connetto_peer_android::request_peer_permissions(); +``` diff --git a/crates/connetto-peer-android/android/build.gradle.kts b/crates/connetto-peer-android/android/build.gradle.kts new file mode 100644 index 00000000..4c15d641 --- /dev/null +++ b/crates/connetto-peer-android/android/build.gradle.kts @@ -0,0 +1,28 @@ +import org.gradle.api.tasks.bundling.AbstractArchiveTask + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "dev.connetto.peer" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +tasks.withType().configureEach { + archiveBaseName.set("dx-native-connetto-peer") +} diff --git a/crates/connetto-peer-android/android/src/main/AndroidManifest.xml b/crates/connetto-peer-android/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..804660b9 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/HotspotPlugin.kt b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/HotspotPlugin.kt new file mode 100644 index 00000000..9a1e1f23 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/HotspotPlugin.kt @@ -0,0 +1,411 @@ +package dev.connetto.peer + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.SoftApConfiguration +import android.net.wifi.WifiManager +import android.net.wifi.WifiNetworkSpecifier +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import java.net.Inet4Address + +/** + * The local-only hotspot the peer link hosts, and the peer hotspots it joins. + * + * All state stands behind [lock], and the [JvmStatic]s are the JNI surface the + * client drives. The host side answers with [HOST_PENDING] until the system + * has started the hotspot, at which point the details stand ready for + * `hostSsid`, `hostPassphrase` and `hostSecurity`. The join side answers with + * [JOIN_PENDING] until the joined network's link properties stand ready, at + * which point the joined address, prefix and gateway stand ready. + * + * The host callbacks are the API 34 signatures, whose `onStarted` hands the + * configured reservation. The platform removed the API 33 signatures, so + * hosting stands on 34 and later only. + */ +class HotspotPlugin { + companion object { + private const val HOST_IDLE = 0 + private const val HOST_PENDING = 1 + private const val HOST_STARTED = 2 + private const val HOST_FAILED = 3 + private const val HOST_STOPPED = 4 + + private const val JOIN_IDLE = 0 + private const val JOIN_PENDING = 1 + private const val JOIN_AVAILABLE = 2 + private const val JOIN_UNAVAILABLE = 3 + private const val JOIN_LOST = 4 + + private const val SECURITY_WPA2 = 0 + private const val SECURITY_WPA3 = 1 + + private const val PERMISSION_REQUEST = 1001 + + private val lock = Any() + private val handler = Handler(Looper.getMainLooper()) + + private var hostState: Int = HOST_IDLE + private var hostSsid: String? = null + private var hostPassphrase: String? = null + private var hostSecurity: Int = SECURITY_WPA2 + private var hostFailure: String? = null + // The host request each callback stands tagged with, so a stale + // callback is told from the current one. + private var hostRequest: Long = 0 + private var reservation: WifiManager.LocalOnlyHotspotReservation? = null + + private var joinState: Int = JOIN_IDLE + private var joinAddress: String? = null + private var joinPrefix: Int = 0 + private var joinGateway: String? = null + private var joinNetwork: Network? = null + private var joinCallback: ConnectivityManager.NetworkCallback? = null + + // The application's context, from its cache, else from the + // framework's own accessor, which the SDK's stubs keep hidden. + private var cachedContext: Context? = null + + @JvmStatic + fun startHost() { + synchronized(lock) { + // A stale reservation from an abandoned request would hold the + // hotspot, so it closes before the new request goes out. + reservation?.close() + reservation = null + hostSsid = null + hostPassphrase = null + hostFailure = null + val context = context() ?: return + if (Build.VERSION.SDK_INT >= 33 && + context.checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) != + PackageManager.PERMISSION_GRANTED + ) { + hostState = HOST_FAILED + hostFailure = Manifest.permission.NEARBY_WIFI_DEVICES + return + } + hostState = HOST_PENDING + val wifi = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + hostRequest += 1 + val request = hostRequest + val callback = object : WifiManager.LocalOnlyHotspotCallback() { + @Suppress("DEPRECATION") + override fun onStarted(started: WifiManager.LocalOnlyHotspotReservation) { + synchronized(lock) { + // The request stands stopped or replaced, so + // the hotspot it started goes with it. + if (request != hostRequest || hostState != HOST_PENDING) { + started.close() + return + } + val config = + runCatching { started.getSoftApConfiguration() } + .getOrNull() + // A hotspot whose details cannot be read has no + // joiners to offer, so it closes with the failure. + if (config == null) { + started.close() + hostState = HOST_FAILED + hostFailure = "0" + return + } + reservation = started + hostSsid = + config.wifiSsid + ?.bytes + ?.let { String(it, Charsets.UTF_8) } + ?: config.ssid + hostPassphrase = config.passphrase + hostSecurity = + if (config.securityType == + SoftApConfiguration.SECURITY_TYPE_WPA2_PSK + ) { + SECURITY_WPA2 + } else { + SECURITY_WPA3 + } + hostState = HOST_STARTED + } + } + + override fun onStopped() { + synchronized(lock) { + if (request != hostRequest) return + if (hostState == HOST_STARTED) { + hostState = HOST_STOPPED + } + } + } + + override fun onFailed(status: Int) { + synchronized(lock) { + if (request != hostRequest) return + if (hostState != HOST_PENDING) return + hostState = HOST_FAILED + hostFailure = when (status) { + // The client's Incompatible and NoChannel + // rows, in the platform's codes. + WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE -> + "1" + + WifiManager.LocalOnlyHotspotCallback.ERROR_NO_CHANNEL -> "2" + + else -> "0" + } + } + } + } + try { + // The API 34 request stands the reservation in its + // `onStarted`, not in the call's answer. + wifi.startLocalOnlyHotspot(callback, handler) + } catch (e: SecurityException) { + hostState = HOST_FAILED + hostFailure = Manifest.permission.NEARBY_WIFI_DEVICES + } catch (e: IllegalStateException) { + // The platform still holds a request this side gave up, + // whose late `onStarted` closes its reservation. + hostState = HOST_FAILED + hostFailure = "0" + } + } + } + + @JvmStatic + fun stopHost() { + synchronized(lock) { + reservation?.close() + reservation = null + hostState = HOST_IDLE + } + } + + @JvmStatic + fun hostState(): Int = synchronized(lock) { hostState } + + @JvmStatic + fun hostSsid(): String? = synchronized(lock) { hostSsid } + + @JvmStatic + fun hostPassphrase(): String? = synchronized(lock) { hostPassphrase } + + @JvmStatic + fun hostSecurity(): Int = synchronized(lock) { hostSecurity } + + @JvmStatic + fun hostFailure(): String? = synchronized(lock) { hostFailure } + + @JvmStatic + fun join(ssid: String, passphrase: String, security: Int, timeoutMs: Long) { + synchronized(lock) { + leaveLocked() + val context = context() ?: return + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) + as ConnectivityManager + val specifierBuilder = WifiNetworkSpecifier.Builder().setSsid(ssid) + if (security == SECURITY_WPA3) { + specifierBuilder.setWpa3Passphrase(passphrase) + } else { + specifierBuilder.setWpa2Passphrase(passphrase) + } + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .setNetworkSpecifier(specifierBuilder.build()) + .build() + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + synchronized(lock) { + if (joinState != JOIN_PENDING) return + joinNetwork = network + } + } + + // The joined network's address, prefix and gateway stand + // in the link properties the platform reports on their + // assignment. + override fun onLinkPropertiesChanged( + network: Network, + linkProperties: LinkProperties + ) { + synchronized(lock) { + if (joinState != JOIN_PENDING || network != joinNetwork) return + fillInFrom(linkProperties) + } + } + + override fun onUnavailable() { + synchronized(lock) { + if (joinState != JOIN_PENDING) return + joinState = JOIN_UNAVAILABLE + // The platform dropped the request, so the + // callback stands unregistered. + joinCallback = null + joinNetwork = null + } + } + + override fun onLost(network: Network) { + synchronized(lock) { + if (joinState == JOIN_PENDING || joinState == JOIN_AVAILABLE) { + joinState = JOIN_LOST + } + unregisterLocked() + } + } + } + try { + cm.requestNetwork(request, callback, timeoutMs.toInt()) + // The request stands registered, so a later leave or + // join unregisters it. + joinCallback = callback + joinState = JOIN_PENDING + } catch (e: Exception) { + joinState = JOIN_UNAVAILABLE + } + } + } + + @JvmStatic + fun leave() { + synchronized(lock) { + leaveLocked() + } + } + + @JvmStatic + fun joinState(): Int = synchronized(lock) { joinState } + + @JvmStatic + fun joinAddress(): String? = synchronized(lock) { joinAddress } + + @JvmStatic + fun joinPrefix(): Int = synchronized(lock) { joinPrefix } + + @JvmStatic + fun joinGateway(): String? = synchronized(lock) { joinGateway } + + // Joined with newlines, so the JNI side, which cannot safely build + // the array's constructor, splits on lines. Empty when nothing is + // missing. + @JvmStatic + fun missingPermissions(): String { + val context = context() ?: return "" + val missing = mutableListOf() + if (Build.VERSION.SDK_INT >= 33 && + context.checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) != + PackageManager.PERMISSION_GRANTED + ) { + missing += Manifest.permission.NEARBY_WIFI_DEVICES + } + if (context.checkSelfPermission(Manifest.permission.CHANGE_WIFI_MULTICAST_STATE) != + PackageManager.PERMISSION_GRANTED + ) { + missing += Manifest.permission.CHANGE_WIFI_MULTICAST_STATE + } + return missing.joinToString("\n") + } + + @JvmStatic + fun requestPermissions(activity: Activity) { + // The prompt's activity stands the context's public route, so the + // cache seeds from it for the JNI-threaded calls. + cachedContext = activity.applicationContext + val missing = missingPermissions() + if (missing.isNotEmpty()) { + activity.requestPermissions( + missing.split("\n").toTypedArray(), + PERMISSION_REQUEST + ) + } + } + + @JvmStatic + fun bindSocket(fd: Int): Boolean { + val network = synchronized(lock) { joinNetwork } ?: return false + val pfd = runCatching { ParcelFileDescriptor.adoptFd(fd) }.getOrNull() + ?: return false + return try { + // The network binds through the file descriptor, and the fd + // is the socket's, not the descriptor's, so Java must never + // close it out from under the dial. + network.bindSocket(pfd.fileDescriptor) + true + } catch (e: Exception) { + false + } finally { + runCatching { pfd.detachFd() } + } + } + + /** The application context, cached once the framework hands it out. */ + private fun context(): Context? { + cachedContext?.let { return it } + val context = contextFromFramework() + if (context != null) cachedContext = context + return context + } + + /** The running application, read through `ActivityThread` by reflection. */ + private fun contextFromFramework(): Context? = runCatching { + val application = + Class.forName("android.app.ActivityThread") + .getMethod("currentApplication") + .invoke(null) + application as? Context + }.getOrNull() + + /** The user's leave, or the join's request replacing it. */ + private fun leaveLocked() { + unregisterLocked() + joinAddress = null + joinPrefix = 0 + joinGateway = null + joinState = JOIN_IDLE + } + + /** + * The network is gone or released, so the callback and the network + * go with it. The state stands, the client steers it. + */ + private fun unregisterLocked() { + val callback = joinCallback ?: return + val context = context() ?: return + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) + as ConnectivityManager + runCatching { cm.unregisterNetworkCallback(callback) } + joinCallback = null + joinNetwork = null + } + + /** + * The joined IPv4 address, prefix and gateway from [linkProperties], + * marking the join available once the address and gateway are known. + */ + private fun fillInFrom(linkProperties: LinkProperties) { + val address = linkProperties.linkAddresses + .firstOrNull { it.address is Inet4Address } + joinAddress = (address?.address as? Inet4Address)?.hostAddress + joinPrefix = address?.prefixLength ?: 0 + val gateway = linkProperties.routes + .firstOrNull { it.isDefaultRoute && it.hasGateway() } + ?.gateway + ?: linkProperties.routes + .firstOrNull { it.hasGateway() } + ?.gateway + joinGateway = (gateway as? Inet4Address)?.hostAddress + if (joinAddress != null && joinGateway != null) { + joinState = JOIN_AVAILABLE + } + } + } +} diff --git a/crates/connetto-peer-android/src/android.rs b/crates/connetto-peer-android/src/android.rs new file mode 100644 index 00000000..372ee7aa --- /dev/null +++ b/crates/connetto-peer-android/src/android.rs @@ -0,0 +1,63 @@ +//! The bundled hotspot Kotlin module, and the prompt for its permissions. + +use manganis::android::with_activity; +use manganis::jni::JNIEnv; +use manganis::jni::errors::Error as JniError; +use manganis::jni::objects::{JClass, JValue}; + +// Declared for `dx`, which builds and bundles the Kotlin module this names. +#[manganis::ffi("android")] +extern "Kotlin" { + pub type HotspotPlugin; +} + +/// The plugin's class, reached through the application's class loader, since +/// a thread Dioxus attached to the VM cannot find app classes with +/// `FindClass`. +const PLUGIN_CLASS: &str = "dev.connetto.peer.HotspotPlugin"; + +/// Ask the operating system for the peer link's permissions, from the plugin +/// standing behind the Activity (R76). +pub fn request_peer_permissions() { + let _ = with_activity(|env, activity| { + let class = plugin(env).ok()?; + env.call_static_method( + class, + "requestPermissions", + "(Landroid/app/Activity;)V", + &[JValue::Object(activity)], + ) + .ok()?; + Some(()) + }); +} + +/// The bundled plugin's class, loaded on the Activity's class loader. +fn plugin<'local>(env: &mut JNIEnv<'local>) -> Result, JniError> { + let application = env + .call_static_method( + "android/app/ActivityThread", + "currentApplication", + "()Landroid/app/Application;", + &[], + )? + .l()?; + let class_loader = env + .call_method( + &application, + "getClassLoader", + "()Ljava/lang/ClassLoader;", + &[], + )? + .l()?; + let name = env.new_string(PLUGIN_CLASS)?; + let class = env + .call_method( + &class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValue::Object(&name)], + )? + .l()?; + Ok(JClass::from(class)) +} diff --git a/crates/connetto-peer-android/src/lib.rs b/crates/connetto-peer-android/src/lib.rs new file mode 100644 index 00000000..db3747bf --- /dev/null +++ b/crates/connetto-peer-android/src/lib.rs @@ -0,0 +1,22 @@ +//! The Android permissions the connetto peer link asks for, and the bundled +//! Kotlin module behind the local-only hotspot the peer link dials through. +//! +//! See the crate's README for the two permissions the peer link needs and +//! where each of them is used. + +#![doc = include_str!("../README.md")] + +#[cfg(target_os = "android")] +mod android; + +#[cfg(target_os = "android")] +pub use android::request_peer_permissions; + +/// Ask the operating system for the permissions the peer link's discovery and +/// the hotspot need (R76), opening the prompt on the Activity when any is +/// missing. Run on the UI thread only. +/// +/// Off Android the peer link's permissions do not exist, so the call does +/// nothing. +#[cfg(not(target_os = "android"))] +pub fn request_peer_permissions() {} diff --git a/crates/connetto-peer/Cargo.toml b/crates/connetto-peer/Cargo.toml index bf078786..e204cda9 100644 --- a/crates/connetto-peer/Cargo.toml +++ b/crates/connetto-peer/Cargo.toml @@ -22,7 +22,7 @@ default = [] discovery = ["dep:mdns-sd", "dep:ring"] # The loopback multicast probe the discovery proofs share with # `connetto-client`'s suite, for test builds only. -test-support = ["dep:socket2"] +test-support = [] [dependencies] connetto-core = { workspace = true, features = ["device-cert"] } @@ -53,8 +53,10 @@ thiserror.workspace = true # The advertisement and the fingerprint hash, behind the discovery feature. mdns-sd = { version = "0.21", optional = true } ring = { version = "0.17", optional = true } -socket2 = { version = "0.6", optional = true } tracing = { version = "0.1", default-features = false, features = ["std"] } +# The dial sockets take their domain here and hand a `SockRef` to the +# socket prep before their connect. +socket2 = "0.6" [dev-dependencies] # The proofs mint roots, issuers, lists and device certificates through the @@ -62,7 +64,5 @@ tracing = { version = "0.1", default-features = false, features = ["std"] } rcgen = { version = "0.14.10", default-features = false, features = ["ring", "x509-parser"] } # `time::pause` drives the 15/45 s liveness clocks in the proofs. tokio = { version = "1", default-features = false, features = ["test-util"] } -# The shared probe in `test_support`, which the crate's own proofs build -# without the feature. -socket2 = "0.6" + uuid = { version = "1", default-features = false } diff --git a/crates/connetto-peer/README.md b/crates/connetto-peer/README.md index 7414df28..b74aafe6 100644 --- a/crates/connetto-peer/README.md +++ b/crates/connetto-peer/README.md @@ -6,6 +6,8 @@ The mutual TLS link between certified devices. A `Node` presents one device's certificate and key and dials or accepts other certified devices over TLS 1.3. Every chain verifies against the roots the deployment ships, the attestation levels the device accepts, and the revocation lists it keeps, behind the wall clock a `Clock` supplies. Links carry a small frame protocol for liveness pings and revocation lists the peer lacks, close under the silence bound, and a deterministic duplicate rule keeps one link per peer key when both sides dial at once. +The dials' sockets pass through the `SocketPrep` the node carries before their connect, set at runtime with `set_socket_prep`, so a joined network can bind them to itself. + ```rust use std::net::SocketAddr; use std::sync::Arc; diff --git a/crates/connetto-peer/src/discovery.rs b/crates/connetto-peer/src/discovery.rs index ec6ad0f4..a4c9753c 100644 --- a/crates/connetto-peer/src/discovery.rs +++ b/crates/connetto-peer/src/discovery.rs @@ -290,6 +290,8 @@ async fn serve_round( } else { warn!("the advertisement will not register, so the browse alone serves"); } + // The round's retry timers, one per fingerprint, ending with it. + let mut retry_timers: HashMap> = HashMap::new(); let re_browse = { loop { tokio::select! { @@ -316,14 +318,14 @@ async fn serve_round( None => policy.linked(fp), Some(reason) => policy.unlinked(Instant::now(), fp, reason), }; - arm_retry(commands_tx, policy, fp); + arm_retry(&mut retry_timers, commands_tx, policy, fp); } // The dial's truth is the link's, so a late outcome // lands on the state the machine holds. Command::DialOutcome { fp, outcome } => { let now = Instant::now(); policy.dial_result(now, fp, outcome); - arm_retry(commands_tx, policy, fp); + arm_retry(&mut retry_timers, commands_tx, policy, fp); // The ended dial frees its place, and the due // dials take their turn. for queued in policy.due(now) { @@ -355,10 +357,7 @@ async fn serve_round( .iter() .map(|ip| SocketAddr::new(ip.to_ip_addr(), resolved.port)) .collect(); - let mut actions = Vec::new(); - for address in addresses { - actions.append(&mut policy.found(&name, fp, address)); - } + let actions = policy.found(&name, fp, &addresses); apply(&actions, fp, node, commands_tx, events, policy); } ServiceEvent::ServiceRemoved(_service_type, fullname) => { @@ -401,6 +400,10 @@ async fn serve_round( } } }; + // The round ends, so its timers go with it. + for (_, timer) in retry_timers.drain() { + timer.abort(); + } if let Some(fullname) = registered { let _ = daemon.unregister(&fullname); } @@ -476,21 +479,40 @@ fn apply( } } -/// The timer that wakes the runner when the instance's retry is due. A -/// replacement deadline leaves the old timer behind, and the late wake finds -/// the dial already in flight. -fn arm_retry(commands_tx: &mpsc::WeakUnboundedSender, policy: &Policy, fp: Fingerprint) { - if let Some(at) = policy.retry_at(&fp) { - let commands_tx = commands_tx.clone(); - let at = tokio::time::Instant::from_std(at); - tokio::spawn(async move { - tokio::time::sleep_until(at).await; - // The driver is gone, so the timer it served is done. - let Some(commands_tx) = commands_tx.upgrade() else { - return; - }; - let _ = commands_tx.send(Command::RetryDue); - }); +/// The retry timer one outcome arms, one per fingerprint and owned by the +/// round. A replacement deadline aborts the earlier timer, the drop of the +/// retry aborts it, and the round's end aborts the rest. +fn arm_retry( + timers: &mut HashMap>, + commands_tx: &mpsc::WeakUnboundedSender, + policy: &Policy, + fp: Fingerprint, +) { + match policy.retry_at(&fp) { + Some(at) => { + if let Some(earlier) = timers.remove(&fp) { + earlier.abort(); + } + let commands_tx = commands_tx.clone(); + let at = tokio::time::Instant::from_std(at); + timers.insert( + fp, + tokio::spawn(async move { + tokio::time::sleep_until(at).await; + // The driver is gone, so the timer it served is done. + let Some(commands_tx) = commands_tx.upgrade() else { + return; + }; + let _ = commands_tx.send(Command::RetryDue); + }), + ); + } + // The retry dropped, so the timer that served it goes with it. + None => { + if let Some(earlier) = timers.remove(&fp) { + earlier.abort(); + } + } } } @@ -608,4 +630,44 @@ mod tests { "a non-hex value is refused" ); } + + /// The retry timer stands one per fingerprint. A replacement deadline + /// aborts the earlier timer, and the round's end aborts the rest. + #[tokio::test] + async fn a_replaced_retry_timer_aborts_its_predecessor() { + use super::{Command, arm_retry}; + use crate::policy::{DialOutcome, FIRST_WAIT, Policy}; + use std::collections::HashMap; + use std::time::{Duration, Instant}; + use tokio::sync::mpsc; + + tokio::time::pause(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let weak = tx.downgrade(); + let fp = Fingerprint::new([1; 32]); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 443)); + let mut policy = Policy::new(Fingerprint::new([0; 32]), true); + policy.found("n", fp, &[addr]); + // The dial ends unreachable, so the first retry is due after the + // first wait, and the next outcome moves the deadline. + policy.dial_result(Instant::now(), fp, DialOutcome::Unreachable); + let mut timers: HashMap> = HashMap::new(); + arm_retry(&mut timers, &weak, &policy, fp); + assert_eq!(timers.len(), 1, "one timer stands for the instance"); + policy.dial_result(Instant::now(), fp, DialOutcome::Unreachable); + arm_retry(&mut timers, &weak, &policy, fp); + assert_eq!(timers.len(), 1, "the replacement stands alone"); + // The earlier deadline passes, and its aborted timer wakes nothing. + tokio::time::advance(FIRST_WAIT + Duration::from_millis(10)).await; + tokio::task::yield_now().await; + assert!(rx.try_recv().is_err(), "the aborted timer woke late"); + // The replacement deadline passes, and its timer wakes once. + tokio::time::advance(Duration::from_secs(5)).await; + tokio::task::yield_now().await; + assert!( + matches!(rx.try_recv().ok(), Some(Command::RetryDue)), + "the replacement wakes its dial" + ); + assert!(rx.try_recv().is_err(), "one wake per timer"); + } } diff --git a/crates/connetto-peer/src/lib.rs b/crates/connetto-peer/src/lib.rs index 0399fc9e..898d696d 100644 --- a/crates/connetto-peer/src/lib.rs +++ b/crates/connetto-peer/src/lib.rs @@ -37,7 +37,7 @@ pub use error::{CloseReason, LinkError, Refusal, TrustError}; pub use event::PeerEvent; pub use frame::PeerFrame; pub use identity::{Clock, Identity, SystemClock, Trust}; -pub use node::{Liveness, Node}; +pub use node::{Liveness, Node, SocketPrep}; #[cfg(feature = "discovery")] pub use discovery::{Discovery, DiscoveryEvent}; diff --git a/crates/connetto-peer/src/node.rs b/crates/connetto-peer/src/node.rs index fb93d432..1cbffe53 100644 --- a/crates/connetto-peer/src/node.rs +++ b/crates/connetto-peer/src/node.rs @@ -16,8 +16,9 @@ use parking_lot::{Mutex, RwLock}; use rustls::pki_types::{CertificateDer, ServerName}; use rustls::{ClientConfig, ServerConfig, version}; use serde_bytes::ByteBuf; +use socket2::SockRef; use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::net::{TcpListener, TcpSocket, TcpStream}; use tokio::sync::{mpsc, watch}; use tokio_rustls::{TlsAcceptor, TlsConnector}; use tracing::{debug, warn}; @@ -68,6 +69,17 @@ impl Default for Liveness { } } +/// Prepares the node's dial sockets before their connect, the one hook a +/// joined network uses to bind them to itself. +pub trait SocketPrep: Send + Sync { + /// Prepare the unbound dial socket `sock` for a dial to `target`. + /// + /// # Errors + /// + /// When the socket cannot be prepared for the target's network. + fn prepare(&self, sock: SockRef<'_>, target: SocketAddr) -> io::Result<()>; +} + /// The node's shared state, behind an `Arc` for its tasks. pub(crate) struct NodeState { trust: Trust, @@ -91,6 +103,9 @@ pub(crate) struct NodeState { pub(crate) events: mpsc::UnboundedSender, pub(crate) ping_every: Duration, pub(crate) silence_limit: Duration, + /// The prep every dial socket passes through before its connect, set at + /// runtime while the joined network comes and goes. + socket_prep: RwLock>>, /// The leaf fingerprints the links carry, per peer key, for discovery's /// mapping of the node's events to its instances. #[cfg(feature = "discovery")] @@ -245,6 +260,7 @@ impl Node { events, ping_every: liveness.ping_every, silence_limit: liveness.silence_limit, + socket_prep: RwLock::new(None), #[cfg(feature = "discovery")] peer_fingerprints: Mutex::new(HashMap::new()), }); @@ -321,6 +337,12 @@ impl Node { stop_state(&self.state, reason); } + /// Prepare the dials' sockets through `prep` before their connect, or + /// stop preparing them with `None`. + pub fn set_socket_prep(&self, prep: Option>) { + *self.state.socket_prep.write() = prep; + } + /// The bound address, while the node serves. #[must_use] pub fn local_addr(&self) -> Option { @@ -361,7 +383,16 @@ impl Node { *dial_count += 1; *dial_count }; - let tcp = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)) + let socket = match addr { + SocketAddr::V4(_) => TcpSocket::new_v4(), + SocketAddr::V6(_) => TcpSocket::new_v6(), + } + .map_err(LinkError::Unreachable)?; + if let Some(prep) = self.state.socket_prep.read().as_ref() { + prep.prepare(SockRef::from(&socket), addr) + .map_err(LinkError::Unreachable)?; + } + let tcp = tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(addr)) .await .map_err(|_| LinkError::Timeout)? .map_err(LinkError::Unreachable)?; diff --git a/crates/connetto-peer/src/policy.rs b/crates/connetto-peer/src/policy.rs index 39bf798e..f61933f6 100644 --- a/crates/connetto-peer/src/policy.rs +++ b/crates/connetto-peer/src/policy.rs @@ -39,11 +39,10 @@ pub(crate) struct Instance { /// registration. name: String, state: InstanceState, - /// The resolved addresses, dialled `IPv4` first. + /// The addresses the last resolve reported it at, dialled `IPv4` + /// first, and the ones already reported as found, which a resolve + /// replaces with its own. addresses: std::collections::BTreeSet, - /// The addresses already reported as found. - reported: std::collections::BTreeSet, - /// The current backoff step, while a retry is pending. wait: Duration, /// When the pending retry is due, on a monotonic clock. retry_at: Option, @@ -117,8 +116,14 @@ impl Policy { self.own = own; } - /// The instance `name`, carrying `fp`, was found or resolved at `addr`. - pub(crate) fn found(&mut self, name: &str, fp: Fingerprint, addr: SocketAddr) -> Vec { + /// The instance `name`, carrying `fp`, was found or resolved at + /// `addrs`, the addresses of one resolve. + pub(crate) fn found( + &mut self, + name: &str, + fp: Fingerprint, + addrs: &[SocketAddr], + ) -> Vec { if fp == self.own { return Vec::new(); } @@ -148,22 +153,26 @@ impl Policy { name: name.to_string(), state: InstanceState::New, addresses: BTreeSet::new(), - reported: BTreeSet::new(), wait: FIRST_WAIT, retry_at: None, seq, }); - // A re-advertisement under a fresh name reports the peer again. + // A re-advertisement under a fresh name reports the peer again, so + // the addresses it carried stand to be reported anew. if entry.name != name { entry.name = name.to_string(); - entry.reported.clear(); + entry.addresses.clear(); } - // The address reports once, and the instance dials once from new, - // while the bound of running dials holds room. - if entry.reported.insert(addr) { - actions.push(Action::Found(addr)); + // A resolve replaces the instance's address set with the addresses + // it resolved at, so a set never grows past one resolve, and an + // address reports once per resolve. + let resolved: BTreeSet = addrs.iter().copied().collect(); + for addr in &resolved { + if !entry.addresses.contains(addr) { + actions.push(Action::Found(*addr)); + } } - entry.addresses.insert(addr); + entry.addresses = resolved; if entry.state == InstanceState::New && self.autolink && capacity > 0 @@ -364,11 +373,14 @@ mod tests { #[test] fn the_own_fingerprint_is_ignored() { let mut policy = autolink(); - assert!(policy.found("n", OWN, ADDR).is_empty(), "a self is ignored"); + assert!( + policy.found("n", OWN, &[ADDR]).is_empty(), + "a self is ignored" + ); assert_eq!(policy.state(&OWN), None); // A renewal moves the own fingerprint, and the new one is ignored too. policy.set_own(RENEWED); - assert_eq!(policy.found("n", RENEWED, ADDR), Vec::new()); + assert_eq!(policy.found("n", RENEWED, &[ADDR]), Vec::new()); assert!(policy.removed(OWN).is_empty(), "a self never reports gone"); } @@ -376,7 +388,7 @@ mod tests { #[test] fn a_found_instance_is_reported_and_dialled() { let mut policy = autolink(); - let actions = policy.found("n", PEER, ADDR); + let actions = policy.found("n", PEER, &[ADDR]); assert_eq!( actions, vec![Action::Found(ADDR), Action::Dial], @@ -384,13 +396,17 @@ mod tests { ); assert_eq!(policy.state(&PEER), Some(InstanceState::New)); // A repeated resolve of the same address reports nothing and dials - // nothing, and a second address reports only itself. - assert_eq!(policy.found("n", PEER, ADDR), Vec::::new()); - assert_eq!(policy.found("n", PEER, ADDR2), vec![Action::Found(ADDR2)]); + // nothing, and a resolve at a second address reports only that + // address and stands alone in the instance's set. + assert_eq!(policy.found("n", PEER, &[ADDR]), Vec::::new()); + assert_eq!( + policy.found("n", PEER, &[ADDR2]), + vec![Action::Found(ADDR2)] + ); assert_eq!( policy.addresses(&PEER), - vec![ADDR, ADDR2], - "every resolved address is kept" + vec![ADDR2], + "the resolve's addresses stand alone" ); } @@ -398,13 +414,13 @@ mod tests { #[test] fn a_linked_instance_is_not_redialled() { let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); assert_eq!(policy.state(&PEER), Some(InstanceState::Linked)); // The browse keeps resolving it, and nothing dials. - assert_eq!(policy.found("n", PEER, ADDR), Vec::::new()); + assert_eq!(policy.found("n", PEER, &[ADDR]), Vec::::new()); assert_eq!( - policy.found("n", PEER, ADDR2), + policy.found("n", PEER, &[ADDR2]), vec![Action::Found(ADDR2)], "a new address reports while linked" ); @@ -414,7 +430,7 @@ mod tests { #[test] fn an_unreachable_dial_backs_off_to_the_cap() { let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); // The first dial ends, and the retry is due after the first wait. let mut due_at = now(); policy.dial_result(due_at, PEER, DialOutcome::Unreachable); @@ -436,20 +452,24 @@ mod tests { fn a_refused_instance_is_barred_until_its_fingerprint_changes() { let mut policy = autolink(); // A dial the peer refuses. - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Refused); assert_eq!(policy.state(&PEER), Some(InstanceState::Barred)); - assert!(!policy.found("n", PEER, ADDR).contains(&Action::Found(ADDR))); assert!( - !policy.found("n", PEER, ADDR2).contains(&Action::Dial), + !policy + .found("n", PEER, &[ADDR]) + .contains(&Action::Found(ADDR)) + ); + assert!( + !policy.found("n", PEER, &[ADDR2]).contains(&Action::Dial), "a barred instance never dials" ); // A dial of the renewed fingerprint starts clean. - let actions = policy.found("n", RENEWED, ADDR); + let actions = policy.found("n", RENEWED, &[ADDR]); assert_eq!(actions, vec![Action::Found(ADDR), Action::Dial]); // A live link the kept list revokes, the other bar reasons included. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); for reason in [ CloseReason::PeerRevoked, @@ -457,7 +477,7 @@ mod tests { CloseReason::Protocol, ] { let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); policy.unlinked(now(), PEER, reason); assert_eq!( @@ -481,7 +501,7 @@ mod tests { CloseReason::Duplicate, ] { let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); policy.unlinked(now(), PEER, reason); assert_eq!( @@ -500,14 +520,14 @@ mod tests { fn a_removal_drops_the_retry_and_keeps_the_live_link() { // The retry, dropped with the entry. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Unreachable); assert_eq!(policy.removed(PEER), vec![Action::Gone]); assert_eq!(policy.state(&PEER), None); assert!(policy.next_retry().is_none()); // The live link: the entry goes, the link is the node's, untouched. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); assert_eq!(policy.removed(PEER), vec![Action::Gone]); assert_eq!(policy.state(&PEER), None); @@ -520,20 +540,20 @@ mod tests { fn a_local_address_change_resets_the_waiting() { // The waiting entry, dropped to new with its retry. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Unreachable); assert_eq!(policy.addresses_changed(), vec![Action::Browse]); assert_eq!(policy.state(&PEER), Some(InstanceState::New)); assert!(policy.next_retry().is_none()); // The linked entry, the link stays. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); policy.addresses_changed(); assert_eq!(policy.state(&PEER), Some(InstanceState::Linked)); // The barred entry, the bar stays. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Refused); policy.addresses_changed(); assert_eq!(policy.state(&PEER), Some(InstanceState::Barred)); @@ -549,7 +569,7 @@ mod tests { ] { let (state, wait) = start; let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); match state { InstanceState::New | InstanceState::Linked => {} InstanceState::Waiting => { @@ -585,9 +605,9 @@ mod tests { // The standing leaves, the runner drops the policy, and the instances // go with it. The proof holds the whole table at once and drops it. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Unreachable); - policy.found("n", OTHER, ADDR2); + policy.found("n", OTHER, &[ADDR2]); policy.set_own(OWN); drop(policy); } @@ -597,7 +617,7 @@ mod tests { fn autolink_off_reports_but_never_dials() { let mut policy = Policy::new(OWN, false); assert_eq!( - policy.found("n", PEER, ADDR), + policy.found("n", PEER, &[ADDR]), vec![Action::Found(ADDR)], "the found peer reports" ); @@ -610,7 +630,10 @@ mod tests { assert_eq!(policy.due(now() + Duration::from_secs(3600)), Vec::new()); // The dial results the table removes never arrive, and the browse // keeps reporting new addresses. - assert_eq!(policy.found("n", PEER, ADDR2), vec![Action::Found(ADDR2)]); + assert_eq!( + policy.found("n", PEER, &[ADDR2]), + vec![Action::Found(ADDR2)] + ); } /// The table's found row, column by column. @@ -619,26 +642,35 @@ mod tests { // New, dial. let mut policy = autolink(); assert_eq!( - policy.found("n", PEER, ADDR), + policy.found("n", PEER, &[ADDR]), vec![Action::Found(ADDR), Action::Dial] ); // Linked, no dial. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); - assert_eq!(policy.found("n", PEER, ADDR2), vec![Action::Found(ADDR2)]); + assert_eq!( + policy.found("n", PEER, &[ADDR2]), + vec![Action::Found(ADDR2)] + ); // Waiting, keep the retry. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Unreachable); let retry = policy.next_retry(); - assert_eq!(policy.found("n", PEER, ADDR2), vec![Action::Found(ADDR2)]); + assert_eq!( + policy.found("n", PEER, &[ADDR2]), + vec![Action::Found(ADDR2)] + ); assert_eq!(policy.next_retry(), retry, "the retry is kept"); // Barred, no dial. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Refused); - assert_eq!(policy.found("n", PEER, ADDR2), vec![Action::Found(ADDR2)]); + assert_eq!( + policy.found("n", PEER, &[ADDR2]), + vec![Action::Found(ADDR2)] + ); } /// A re-advertisement under a fresh name, same fingerprint and address, @@ -647,19 +679,25 @@ mod tests { fn a_fresh_name_readvertised_reports_the_peer_again() { let mut policy = autolink(); assert_eq!( - policy.found("old", PEER, ADDR), + policy.found("old", PEER, &[ADDR]), vec![Action::Found(ADDR), Action::Dial] ); // The same name resolves again, nothing reports. - assert_eq!(policy.found("old", PEER, ADDR), Vec::::new()); + assert_eq!(policy.found("old", PEER, &[ADDR]), Vec::::new()); // A fresh name, same fingerprint and address, reports again while // the first dial is still in flight, so no second dial starts. - assert_eq!(policy.found("fresh", PEER, ADDR), vec![Action::Found(ADDR)]); + assert_eq!( + policy.found("fresh", PEER, &[ADDR]), + vec![Action::Found(ADDR)] + ); // A linked peer re-advertised reports without a dial. let mut policy = autolink(); - policy.found("old", PEER, ADDR); + policy.found("old", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); - assert_eq!(policy.found("fresh", PEER, ADDR), vec![Action::Found(ADDR)]); + assert_eq!( + policy.found("fresh", PEER, &[ADDR]), + vec![Action::Found(ADDR)] + ); assert_eq!(policy.state(&PEER), Some(InstanceState::Linked)); } @@ -669,12 +707,12 @@ mod tests { fn the_dial_rows_hold() { // A dial from a new instance lands linked. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); assert_eq!(policy.state(&PEER), Some(InstanceState::Linked)); // A dial from the waiting, the backoff doubling. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Unreachable); let due = now() + Duration::from_secs(5); let _ = policy.due(due); @@ -687,7 +725,7 @@ mod tests { #[test] fn a_dial_result_never_demotes_a_live_link() { let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); // The other addresses gave up, or the peer refused them, after the // inbound link landed. @@ -697,7 +735,7 @@ mod tests { assert_eq!(policy.state(&PEER), Some(InstanceState::Linked)); // A bar, set by the close, is sticky under the late dial result. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Linked); policy.unlinked(now(), PEER, CloseReason::PeerRevoked); policy.dial_result(now(), PEER, DialOutcome::Linked); @@ -726,14 +764,14 @@ mod tests { for i in 1..=bound { assert!( policy - .found("n", fp_at(i), addr_at(i)) + .found("n", fp_at(i), &[addr_at(i)]) .contains(&Action::Dial), "a running dial starts" ); } let queued = fp_at(bound + 1); assert_eq!( - policy.found("n", queued, addr_at(bound + 1)), + policy.found("n", queued, &[addr_at(bound + 1)]), vec![Action::Found(addr_at(bound + 1))], "the dial behind the bound is queued" ); @@ -751,11 +789,11 @@ mod tests { fn a_queued_retry_takes_its_turn_when_a_running_dial_ends() { let mut policy = autolink(); // The retry's deadline passes while the bound of dials runs. - policy.found("n", fp_at(1), addr_at(1)); + policy.found("n", fp_at(1), &[addr_at(1)]); policy.dial_result(now(), fp_at(1), DialOutcome::Unreachable); let bound = u16::try_from(MAX_DIALS).expect("the dial bound holds a u16"); for i in 2..=bound + 1 { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } let due_at = now() + FIRST_WAIT; assert!( @@ -775,7 +813,7 @@ mod tests { let bound = u16::try_from(MAX_DIALS).expect("the dial bound holds a u16"); let queue = |policy: &mut Policy| { assert_eq!( - policy.found("n", fp_at(bound + 1), addr_at(bound + 1)), + policy.found("n", fp_at(bound + 1), &[addr_at(bound + 1)]), vec![Action::Found(addr_at(bound + 1))], "the running dial holds its slot" ); @@ -788,7 +826,7 @@ mod tests { // The instance is removed while its dial runs. let mut policy = autolink(); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } assert_eq!(policy.removed(fp_at(3)), vec![Action::Gone]); queue(&mut policy); @@ -796,7 +834,7 @@ mod tests { // An inbound link lands while the dial runs. let mut policy = autolink(); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } policy.linked(fp_at(3)); assert_eq!(policy.state(&fp_at(3)), Some(InstanceState::Linked)); @@ -805,7 +843,7 @@ mod tests { // A barred close lands while the dial runs. let mut policy = autolink(); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } policy.linked(fp_at(3)); policy.unlinked(now(), fp_at(3), CloseReason::PeerRevoked); @@ -814,14 +852,14 @@ mod tests { settle(&mut policy); // The local addresses change while a waiting retry's dial runs. let mut policy = autolink(); - policy.found("n", PEER, ADDR); + policy.found("n", PEER, &[ADDR]); policy.dial_result(now(), PEER, DialOutcome::Unreachable); let due_at = now() + FIRST_WAIT; assert_eq!(policy.due(due_at), vec![PEER], "the retry dials"); assert_eq!(policy.addresses_changed(), vec![Action::Browse]); assert_eq!(policy.state(&PEER), Some(InstanceState::New)); assert_eq!( - policy.found("n", PEER, ADDR2), + policy.found("n", PEER, &[ADDR2]), vec![Action::Found(ADDR2)], "no second dial for a running one" ); @@ -841,13 +879,13 @@ mod tests { let mut policy = autolink(); let bound = u16::try_from(MAX_DIALS).expect("the dial bound holds a u16"); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } // The older instance carries the higher fingerprint. let older = fp_at(bound + 2); let younger = fp_at(bound + 1); - policy.found("n", older, addr_at(bound + 2)); - policy.found("n", younger, addr_at(bound + 1)); + policy.found("n", older, &[addr_at(bound + 2)]); + policy.found("n", younger, &[addr_at(bound + 1)]); assert!(policy.due(now()).is_empty(), "the bound holds while full"); policy.dial_result(now(), fp_at(1), DialOutcome::Linked); assert_eq!( @@ -866,10 +904,10 @@ mod tests { // The oldest tracked instance, not linked, is evicted. let mut policy = autolink(); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } assert_eq!( - policy.found("n", beyond, addr_at(bound + 1)), + policy.found("n", beyond, &[addr_at(bound + 1)]), vec![Action::Found(addr_at(bound + 1))], "the find reports, without an eviction event" ); @@ -881,11 +919,11 @@ mod tests { // A linked instance is kept, and its successor is evicted. let mut policy = autolink(); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } policy.dial_result(now(), fp_at(1), DialOutcome::Linked); assert_eq!( - policy.found("n", beyond, addr_at(bound + 1)), + policy.found("n", beyond, &[addr_at(bound + 1)]), vec![Action::Found(addr_at(bound + 1)), Action::Dial], "the evicted dial still holds one place" ); @@ -898,13 +936,13 @@ mod tests { // A bound full of linked instances takes no find. let mut policy = autolink(); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } for i in 1..=bound { policy.dial_result(now(), fp_at(i), DialOutcome::Linked); } assert!( - policy.found("n", beyond, addr_at(bound + 1)).is_empty(), + policy.found("n", beyond, &[addr_at(bound + 1)]).is_empty(), "a bound full of links takes no find" ); assert_eq!(policy.state(&beyond), None); @@ -912,13 +950,38 @@ mod tests { // A find of a tracked instance takes no eviction. let mut policy = autolink(); for i in 1..=bound { - policy.found("n", fp_at(i), addr_at(i)); + policy.found("n", fp_at(i), &[addr_at(i)]); } assert_eq!( - policy.found("n", fp_at(1), addr_at(bound + 1)), + policy.found("n", fp_at(1), &[addr_at(bound + 1)]), vec![Action::Found(addr_at(bound + 1))], "a tracked instance re-found takes no eviction" ); assert_eq!(policy.state(&fp_at(2)), Some(InstanceState::New)); } + + /// A resolve replaces the instance's address set with the addresses it + /// resolved at, so a set never grows past one resolve. + #[test] + fn a_resolve_replaces_the_instance_addresses() { + let mut policy = autolink(); + policy.found("n", PEER, &[ADDR]); + // A second resolve at a different address replaces the first. + assert_eq!( + policy.found("n", PEER, &[ADDR2]), + vec![Action::Found(ADDR2)], + "the new address reports once" + ); + assert_eq!( + policy.addresses(&PEER), + vec![ADDR2], + "the resolve's addresses stand alone" + ); + // A repeated resolve of the same set reports nothing. + assert_eq!( + policy.found("n", PEER, &[ADDR2]), + Vec::::new(), + "a repeat reports nothing" + ); + } } diff --git a/crates/connetto-peer/src/tests.rs b/crates/connetto-peer/src/tests.rs index 6c056265..e9a2fdcd 100644 --- a/crates/connetto-peer/src/tests.rs +++ b/crates/connetto-peer/src/tests.rs @@ -4,25 +4,29 @@ //! which also completes a raw TLS peer through the crate's own client config //! so it can stop reading and sending after the handshake. +use std::io; use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, SystemTime}; +use parking_lot::Mutex; + +use crate::frame::{PeerFrame, read_frame, write_frame}; +use crate::node::client_config_for; +use crate::{ + CloseReason, Identity, LinkError, Liveness, Node, PeerEvent, Refusal, SocketPrep, SystemClock, + Trust, +}; use connetto_core::device_cert::{ AttestationLevel, CertificateRequest, CertificateSigner, DeploymentId, DeviceCertificate, DeviceIdentity, DeviceIssuer, DeviceKey, DeviceKeyError, KeyHome, Revoked, RootCa, key_id, public_key_info, }; use rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256, SigningKey as RcgenSigningKey}; +use socket2::SockRef; use tokio::sync::mpsc; use tokio::time; -use crate::frame::{PeerFrame, read_frame, write_frame}; -use crate::node::client_config_for; -use crate::{ - CloseReason, Identity, LinkError, Liveness, Node, PeerEvent, Refusal, SystemClock, Trust, -}; - const DAY: Duration = Duration::from_hours(24); /// A device key held in memory, standing in for a chip. @@ -258,6 +262,16 @@ impl crate::Clock for ShiftedClock { } } +/// A clock fixed at a moment, for the epoch proofs. +#[derive(Debug)] +struct FixedClock(SystemTime); + +impl crate::Clock for FixedClock { + fn now(&self) -> SystemTime { + self.0 + } +} + /// A trust over `roots` accepting `accepted`, behind `clock`. fn node_on( roots: Vec>, @@ -822,6 +836,98 @@ async fn a_clock_outside_the_window_refuses_and_a_small_skew_links() { tolerant_node.stop(CloseReason::Closed); } +/// A clock standing seconds after the epoch skips the attempt before the +/// epoch and refuses with a typed reason, and a clock standing before the +/// epoch, where no attempt is representable, refuses the chain. +#[tokio::test] +async fn a_clock_at_the_epoch_refuses_without_a_panic() { + let now = whole_second(); + let deployment = Deployment::new(22, now); + let issuer = deployment.add_issuer(now, [1; 16]); + + let honest = deployment.device( + &issuer, + "honest", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let early = deployment.device( + &issuer, + "early", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + let pre = deployment.device( + &issuer, + "pre", + now, + DAY, + [3; 16], + AttestationLevel::Unproven, + ); + + let (o_tx, mut o_rx) = events(); + let honest_node = node(deployment.root_der(), o_tx); + // The early node's clock stands ten seconds after the epoch, so its + // minus-tolerance attempt stands before the epoch. + let (s_tx, mut s_rx) = events(); + let early_node = node_on( + vec![deployment.root_der()], + all_levels(), + Arc::new(FixedClock(SystemTime::UNIX_EPOCH + Duration::from_secs(10))), + s_tx, + ); + // The pre node's clock stands ten minutes before the epoch, so no + // attempt of its verifier is representable. + let (p_tx, mut p_rx) = events(); + let pre_node = node_on( + vec![deployment.root_der()], + all_levels(), + Arc::new(FixedClock( + SystemTime::UNIX_EPOCH - Duration::from_secs(600), + )), + p_tx, + ); + + let honest_addr = serve(&honest_node, &honest); + serve(&early_node, &early); + serve(&pre_node, &pre); + + // The early clock's dial meets the refusal its verifier stands, with the + // attempt before the epoch skipped. + let err = early_node + .link(honest_addr) + .await + .expect_err("refused by the early clock"); + assert!( + matches!(err, LinkError::Refused(Refusal::NotYetValid)), + "got {err:?}" + ); + + // The pre clock's dial meets the refusal no representable attempt leaves. + let err = pre_node + .link(honest_addr) + .await + .expect_err("refused by the pre clock"); + assert!( + matches!(err, LinkError::Refused(Refusal::Profile)), + "got {err:?}" + ); + + // No events on either side for the refused handshakes. + no_events(&mut o_rx, Duration::from_secs(1)).await; + no_events(&mut s_rx, Duration::from_secs(1)).await; + no_events(&mut p_rx, Duration::from_secs(1)).await; + + honest_node.stop(CloseReason::Closed); + early_node.stop(CloseReason::Closed); + pre_node.stop(CloseReason::Closed); +} + /// Proof 6. #[tokio::test] async fn an_unaccepted_attestation_level_is_refused() { @@ -2281,3 +2387,105 @@ async fn a_stopped_node_dials_nobody() { assert!(matches!(a.link(b_addr).await, Err(LinkError::NotServing))); no_events(&mut b_rx, Duration::from_millis(500)).await; } + +/// A prep that records every dial target it prepares. +struct RecordingPrep { + targets: Arc>>, +} + +impl SocketPrep for RecordingPrep { + fn prepare(&self, _sock: SockRef<'_>, target: SocketAddr) -> io::Result<()> { + self.targets.lock().push(target); + Ok(()) + } +} + +/// A prep that refuses every dial socket. +struct RefusingPrep; + +impl SocketPrep for RefusingPrep { + fn prepare(&self, _sock: SockRef<'_>, _target: SocketAddr) -> io::Result<()> { + Err(io::Error::other("the prep refuses the socket")) + } +} + +/// The dial socket passes through the prep before its connect, and the +/// link still completes. +#[tokio::test] +async fn a_dial_socket_passes_through_its_prep_before_its_connect() { + let now = whole_second(); + let deployment = Deployment::new(1, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let alice = deployment.device( + &issuer, + "alice", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let bob = deployment.device( + &issuer, + "bob", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + let (a_tx, _a_rx) = events(); + let (b_tx, _b_rx) = events(); + let a = node(deployment.root_der(), a_tx); + let b = node(deployment.root_der(), b_tx); + let a_addr = serve(&a, &alice); + serve(&b, &bob); + let targets = Arc::new(Mutex::new(Vec::new())); + let prep: Arc = Arc::new(RecordingPrep { + targets: Arc::clone(&targets), + }); + b.set_socket_prep(Some(prep)); + assert_eq!( + b.link(a_addr).await.expect("the link completes"), + alice.identity + ); + assert_eq!(targets.lock().as_slice(), &[a_addr]); +} + +/// A refusing prep stops the dial with `Unreachable`. +#[tokio::test] +async fn a_refusing_prep_refuses_the_dial_as_unreachable() { + let now = whole_second(); + let deployment = Deployment::new(1, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let alice = deployment.device( + &issuer, + "alice", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let bob = deployment.device( + &issuer, + "bob", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + let (a_tx, _a_rx) = events(); + let (b_tx, _b_rx) = events(); + let a = node(deployment.root_der(), a_tx); + let b = node(deployment.root_der(), b_tx); + let a_addr = serve(&a, &alice); + serve(&b, &bob); + b.set_socket_prep(Some(Arc::new(RefusingPrep))); + assert!(matches!( + b.link(a_addr).await, + Err(LinkError::Unreachable(_)) + )); + b.set_socket_prep(None); + assert_eq!( + b.link(a_addr).await.expect("the link completes"), + alice.identity + ); +} diff --git a/crates/connetto-peer/src/verify.rs b/crates/connetto-peer/src/verify.rs index e4673c98..57a78b30 100644 --- a/crates/connetto-peer/src/verify.rs +++ b/crates/connetto-peer/src/verify.rs @@ -128,11 +128,15 @@ impl PeerVerifier { let mut first: Option = None; let mut fatal: Option = None; for attempt in attempts { - let time = UnixTime::since_unix_epoch( - attempt - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .expect("the clock stands after the epoch"), - ); + // An attempt before the epoch stands in no representable time, + // so it skips. + let Some(secs) = attempt + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .ok() + else { + continue; + }; + let time = UnixTime::since_unix_epoch(secs); match leaf.verify_for_usage( self.algs.all, anchors, @@ -173,9 +177,12 @@ impl PeerVerifier { } } } - Err(classify( - &fatal.or(first).expect("every attempt records its error"), - )) + match fatal.or(first) { + Some(err) => Err(classify(&err)), + // Every attempt stood before the epoch, so the chain is + // refused, not the verifier. + None => Err(Refusal::Profile), + } } } diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index 84be08e0..97c8ce3d 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -8,7 +8,8 @@ //! ```text //! cargo build -p connetto-test-harness --bin connetto-android-proof //! cargo run -p connetto-test-harness --bin connetto-demo-stack -- \ -//! target/debug/connetto-android-proof [--serial SERIAL] [--apk PATH] [--unlock-pin PIN] +//! target/debug/connetto-android-proof [--serial SERIAL] [--apk PATH] \ +//! [--unlock-pin PIN] [--peer-serial SECOND] //! ``` //! //! The demo keeps its secrets behind the Keystore gate (R52), so each launch @@ -29,6 +30,14 @@ //! The offline step restarts the host's adb server, which drops every other //! session's forwards and reverses on this machine too. A wireless serial //! (`host:port`) is reconnected after the restart. +//! +//! With `--peer-serial` a second attached phone joins the proof. The main +//! phone hosts a hotspot from the demo and the second signs in as its own +//! account and joins it through the demo's join form, the driver tapping the +//! system's approval in place of the user (R76 decision 16). Each phone's +//! server path stays over `adb reverse`, so the joiner reaches the server +//! while it is off its usual Wi-Fi. The proof ends with both phones back on +//! their usual networks. use std::path::{Path, PathBuf}; use std::time::Duration; @@ -67,6 +76,16 @@ const GATE_TRACE_TARGETS: [&str; 2] = [ "\"target\":\"connetto_client::away\"", "\"target\":\"connetto_dioxus::away\"", ]; +/// The second phone's account in the peer proof, so each page's `linked:` +/// line names the other phone unambiguously. +const PEER_USER: &str = "bob"; +/// The localized texts of the system's network-approval dialog button, for +/// the locales the proof phones run, which the driver taps in place of the +/// user (R76 decision 16). +const CONNECT_BUTTONS: [&str; 3] = ["Connect", "Connetti", "Verbinden"]; +/// The silence a link gets to end after the phones leave the hotspot, on +/// either side. +const LINK_END_BOUND: Duration = Duration::from_secs(120); #[tokio::main] async fn main() -> Result<()> { @@ -75,6 +94,7 @@ async fn main() -> Result<()> { serial, apk, unlock_pin, + peer_serial, } = cli_arguments()?; let device = Device::pick(serial).await?; let evidence = repo_path(&["target", "android-proof"])?.join(format!( @@ -99,7 +119,29 @@ async fn main() -> Result<()> { pin: unlock_pin.as_deref(), }; let outcome = match device.hide_error_dialogs().await { - Ok(()) => prove(&device, apk, &stack, &evidence, unlock).await, + Ok(()) => { + let apk = match apk { + Some(apk) => apk, + None => build_apk(&device.rust_target().await?).await?, + }; + let outcome = prove(&device, &apk, &stack, &evidence, unlock).await; + // The peer proof runs only on a proved phone, and a failed + // proof keeps its own error. + match (peer_serial, outcome) { + (Some(serial), Ok(())) => { + peer_proof( + &device, + &Device::new(serial), + &apk, + &stack, + &evidence, + unlock, + ) + .await + } + (_, outcome) => outcome, + } + } Err(err) => Err(err), }; let log = device.adb(&["logcat", "-d"]).await.unwrap_or_default(); @@ -176,15 +218,11 @@ impl Stack { async fn prove( device: &Device, - apk: Option, + apk: &Path, stack: &Stack, evidence: &Path, unlock: Unlock<'_>, ) -> Result<()> { - let apk = match apk { - Some(apk) => apk, - None => build_apk(&device.rust_target().await?).await?, - }; // A paused charge ends a `usb` stay-on, and a sleeping screen locks the phone. device .adb(&["shell", "svc", "power", "stayon", "true"]) @@ -208,7 +246,7 @@ async fn prove( device.launch().await?; let mut tab = device.login_tab(&stack.issuer).await?; device.screenshot(evidence, "login-page").await?; - submit_login(&mut tab).await?; + submit_login(&mut tab, USER).await?; drop(tab); unlock.approve(device, evidence, "launch-unlock").await?; let mut app = device.app().await?; @@ -322,7 +360,7 @@ async fn sign_in_across_a_killed_process( ); } } - submit_login(&mut tab).await?; + submit_login(&mut tab, USER).await?; drop(tab); unlock.approve(device, evidence, "restart-unlock").await?; let deadline = Instant::now() + Duration::from_secs(60); @@ -364,6 +402,349 @@ async fn recheck_after_time_away( device.screenshot(evidence, "unlocked").await } +/// The offer a hosting phone shows, its lines read off the page. +struct HotspotOfferLines { + /// The hotspot's ssid. + ssid: String, + /// The hotspot's passphrase. + passphrase: String, + /// The host's peer port. + port: u16, +} + +/// The offer a hosting phone shows, its lines read off the page, bounded. +async fn read_hotspot_offer(app: &mut PageSession) -> Result { + app.wait_for_text("hotspot ssid: ", Duration::from_secs(45)) + .await + .context("the host never showed its offer")?; + let deadline = Instant::now() + Duration::from_secs(45); + loop { + let page = app.page_text().await?; + if let Some(offer) = parse_offer_lines(&page) { + return Ok(offer); + } + if Instant::now() >= deadline { + bail!("the offer's lines never finished showing, the page shows:\n{page}"); + } + sleep(Duration::from_millis(500)).await; + } +} + +/// The two-phone peer proof (R76 slice 4): `host` hosts a hotspot from the +/// demo and `joiner` signs in as its own account and joins it, the driver +/// tapping the system's approval in place of the user (decision 16), until +/// both pages name the other as linked. It ends with both phones back on +/// their usual networks. +async fn peer_proof( + host: &Device, + joiner: &Device, + apk: &Path, + stack: &Stack, + evidence: &Path, + unlock: Unlock<'_>, +) -> Result<()> { + if !demo_has_device_identity() { + bail!("the peer proof needs a demo build with its device identity"); + } + step("prepare the peer phone"); + let (stay_on, previous_role) = prepare_joiner(joiner, &unlock).await?; + // The joiner's usual network, read after the wireless reconnect, which + // the cleanup must bring it back to. A failed read still runs the + // cleanup below before it reports. + let result = match joiner.current_ssid().await { + Ok(usual_ssid) => { + run_peer_proof( + host, + joiner, + apk, + stack, + evidence, + &unlock, + usual_ssid.as_ref(), + ) + .await + } + Err(err) => Err(err), + }; + + // The joiner's log for the record, and its state, which the run leaves + // as it found it. + let log = joiner.adb(&["logcat", "-d"]).await.unwrap_or_default(); + tokio::fs::write(evidence.join("peer-logcat.txt"), log) + .await + .context("writing the peer phone's log")?; + let restore_role = match &previous_role { + Some(holder) => joiner.set_browser_role_holder(holder).await, + None => joiner + .adb(&[ + "shell", + "cmd", + "role", + "remove-role-holder", + BROWSER_ROLE, + BROWSER, + ]) + .await + .map(drop), + }; + let restore_stay = joiner + .restore_global_setting(STAY_ON, stay_on.as_deref()) + .await; + let _ = joiner.adb(&["forward", "--remove-all"]).await; + let _ = joiner.adb(&["reverse", "--remove-all"]).await; + let _ = joiner.adb(&["shell", "am", "force-stop", PACKAGE]).await; + result.and(restore_role).and(restore_stay) +} + +/// Reconnect a wireless joiner over `adb connect`, hold it awake and +/// unlocked, and take the browser role the login tab wants, keeping the +/// stay-on and role state the run restores. +async fn prepare_joiner( + joiner: &Device, + unlock: &Unlock<'_>, +) -> Result<(Option, Option)> { + // A wireless joiner lost its transport when the single-phone proof + // restarted the host's adb server. + if joiner.serial.contains(':') { + let status = Command::new("adb") + .args(["connect", &joiner.serial]) + .status() + .await + .context("starting adb")?; + if !status.success() { + bail!("adb connect {} exited with {status}", joiner.serial); + } + } + joiner.adb(&["wait-for-device"]).await?; + let stay_on = joiner.global_setting(STAY_ON).await?; + let previous_role = joiner.browser_role_holder().await?; + joiner + .adb(&["shell", "svc", "power", "stayon", "true"]) + .await?; + joiner + .adb(&["shell", "input", "keyevent", "KEYCODE_WAKEUP"]) + .await?; + unlock.dismiss_keyguard(joiner).await?; + joiner.set_browser_role_holder(BROWSER).await?; + Ok((stay_on, previous_role)) +} + +/// The walk itself: install and sign in the joiner, host on the first phone, +/// join from the second, link, end the link, and bring the joiner back to +/// its usual network. +async fn run_peer_proof( + host: &Device, + joiner: &Device, + apk: &Path, + stack: &Stack, + evidence: &Path, + unlock: &Unlock<'_>, + usual_ssid: Option<&String>, +) -> Result<()> { + joiner + .adb(&["install", "-r", "-t", &apk.display().to_string()]) + .await?; + joiner.adb(&["shell", "pm", "clear", PACKAGE]).await?; + joiner.adb(&["reverse", "--remove-all"]).await?; + for (device_port, host_port) in &stack.reverse { + joiner.reverse(*device_port, *host_port).await?; + } + for phone in [host, joiner] { + phone + .adb(&[ + "shell", + "pm", + "grant", + PACKAGE, + "android.permission.NEARBY_WIFI_DEVICES", + ]) + .await?; + } + + let mut peer_app = sign_in_joiner(joiner, stack, evidence, unlock).await?; + + let mut app = host.app().await?; + app.wait_for_text("device: certified until", Duration::from_secs(60)) + .await?; + + let host_identity = own_identity(&mut app).await?; + let joiner_identity = own_identity(&mut peer_app).await?; + + step("host a hotspot on the first phone"); + // An A35 refuses to start an access point while its own station sits on + // a 5 GHz channel, measured on eduroam, so the host leaves its network, + // as a host in the field has none. Its server path stays on USB. + host.adb(&["shell", "svc", "wifi", "disable"]).await?; + sleep(Duration::from_secs(5)).await; + let hosted = host_and_link( + host, + joiner, + &mut app, + &mut peer_app, + evidence, + &host_identity, + &joiner_identity, + ) + .await; + let restored = host + .adb(&["shell", "svc", "wifi", "enable"]) + .await + .map(drop); + let offer_ssid = hosted?; + restored?; + + restore_joiner_networks(host, joiner, &offer_ssid, usual_ssid).await?; + joiner.screenshot(evidence, "peer-clean").await?; + Ok(()) +} + +/// Host on the first phone, join from the second, link, and end the link, +/// answering the hotspot's ssid for the cleanup. +async fn host_and_link( + host: &Device, + joiner: &Device, + app: &mut PageSession, + peer_app: &mut PageSession, + evidence: &Path, + host_identity: &str, + joiner_identity: &str, +) -> Result { + app.click("Host a hotspot").await?; + let offer = read_hotspot_offer(app).await?; + host.screenshot(evidence, "host-offer").await?; + + step("fill the join form on the second phone"); + peer_app.set_input("ssid", &offer.ssid).await?; + peer_app.set_input("passphrase", &offer.passphrase).await?; + peer_app.set_input("port", &offer.port.to_string()).await?; + peer_app.click("Join the hotspot").await?; + + step("approve the network on the second phone"); + joiner.tap_connect_button().await?; + + step("link through the hotspot"); + if app + .wait_for_text( + &format!("linked: {joiner_identity}"), + Duration::from_secs(90), + ) + .await + .is_err() + { + let peer_page = peer_app.page_text().await.unwrap_or_default(); + bail!( + "the first phone never named the second as linked, the second phone shows:\n{peer_page}" + ); + } + peer_app + .wait_for_text(&format!("linked: {host_identity}"), Duration::from_secs(30)) + .await?; + host.screenshot(evidence, "peer-linked").await?; + joiner.screenshot(evidence, "peer-linked").await?; + + step("leave the hotspot on the second phone"); + peer_app.click("Leave the hotspot").await?; + step("stop the hotspot on the first phone"); + app.click("Stop the hotspot").await?; + wait_link_ends(app, peer_app).await?; + host.screenshot(evidence, "peer-unlinked").await?; + Ok(offer.ssid) +} + +/// The page's own peer identity, `account/key prefix`, from its listening +/// line, which the wait polls until the line carries it. +async fn own_identity(app: &mut PageSession) -> Result { + // The peer panel updates the listening line on its own poll, which the + // device label does not wait for, so the wait polls the line itself. + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let page = app.page_text().await?; + if let Some(identity) = page + .lines() + .find_map(|line| line.strip_prefix("peer: listening on ")) + .and_then(|rest| rest.split_once(" as ")) + .map(|(_, identity)| identity.trim().to_owned()) + { + return Ok(identity); + } + if Instant::now() >= deadline { + bail!("the page names no peer identity"); + } + sleep(Duration::from_millis(500)).await; + } +} + +/// Sign the joiner in as its own account and wait for it to connect and +/// certify, leaving its app page open. +async fn sign_in_joiner( + joiner: &Device, + stack: &Stack, + evidence: &Path, + unlock: &Unlock<'_>, +) -> Result { + step("sign in the peer phone"); + joiner.launch().await?; + let mut tab = joiner.login_tab(&stack.issuer).await?; + joiner.screenshot(evidence, "peer-login-page").await?; + submit_login(&mut tab, PEER_USER).await?; + drop(tab); + unlock + .approve(joiner, evidence, "peer-launch-unlock") + .await?; + let mut peer_app = joiner.app().await?; + peer_app + .wait_for_text("status: connected", Duration::from_secs(90)) + .await?; + peer_app + .wait_for_text("device: certified until", Duration::from_secs(60)) + .await?; + joiner.screenshot(evidence, "peer-signed-in").await?; + Ok(peer_app) +} + +/// Wait for both pages to drop their linked lines, which the leave and stop +/// end the link. +async fn wait_link_ends(app: &mut PageSession, peer_app: &mut PageSession) -> Result<()> { + let deadline = Instant::now() + LINK_END_BOUND; + loop { + let host_page = app.page_text().await?; + let peer_page = peer_app.page_text().await?; + if !host_page.contains("linked: ") && !peer_page.contains("linked: ") { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the link did not end after the phones left the hotspot"); + } + sleep(Duration::from_secs(2)).await; + } +} + +/// Forget the hotspot's saved profile on both phones, best effort, and wait +/// for the joiner to fall back to its usual network. +async fn restore_joiner_networks( + host: &Device, + joiner: &Device, + ssid: &str, + usual_ssid: Option<&String>, +) -> Result<()> { + step("clean up the networks"); + // A saved profile the platform may have kept, best effort. + for phone in [host, joiner] { + phone.forget_network(ssid).await?; + } + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let current = joiner.current_ssid().await?; + if current.as_deref() == usual_ssid.map(String::as_str) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the peer phone is back on {current:?}, not {usual_ssid:?}"); + } + sleep(Duration::from_secs(2)).await; + } +} + /// Who answers the platform's unlock prompt. #[derive(Clone, Copy)] struct Unlock<'a> { @@ -433,10 +814,10 @@ impl Unlock<'_> { /// Type the dev user into the identity provider's form and submit it. Trusted /// input is a user gesture, so the browser follows the final redirect into the /// app, which is the path a real tap takes. -async fn submit_login(tab: &mut PageSession) -> Result<()> { +async fn submit_login(tab: &mut PageSession, user: &str) -> Result<()> { tab.evaluate("document.querySelector('input[name=username]').focus()") .await?; - tab.call("Input.insertText", serde_json::json!({ "text": USER })) + tab.call("Input.insertText", serde_json::json!({ "text": user })) .await?; for kind in ["keyDown", "keyUp"] { tab.call( @@ -465,6 +846,7 @@ struct Arguments { serial: Option, apk: Option, unlock_pin: Option, + peer_serial: Option, } fn cli_arguments() -> Result { @@ -476,6 +858,7 @@ fn cli_arguments() -> Result { "--serial" => arguments.serial = Some(value), "--apk" => arguments.apk = Some(PathBuf::from(value)), "--unlock-pin" => arguments.unlock_pin = Some(value), + "--peer-serial" => arguments.peer_serial = Some(value), other => bail!("unknown argument {other}"), } } @@ -765,6 +1148,90 @@ impl Device { .map(drop) } + /// The ssid the device is connected to, which its `dumpsys wifi` names, + /// and `None` while it names none. + async fn current_ssid(&self) -> Result> { + let dump = self.adb(&["shell", "dumpsys", "wifi"]).await?; + Ok(parse_current_ssid(&dump)) + } + + /// Forget a saved network profile by its ssid, best effort, since the + /// platform's one-shot request keeps none of its own. + async fn forget_network(&self, ssid: &str) -> Result<()> { + let listing = self.adb(&["shell", "cmd", "wifi", "list-networks"]).await?; + let Some(id) = saved_network_id(&listing, ssid) else { + return Ok(()); + }; + self.adb(&["shell", "cmd", "wifi", "forget-network", &id]) + .await + .map(drop) + } + + /// Tap the system's network-approval dialog button, in the user's place + /// (R76 decision 16), waiting for it to show. + async fn tap_connect_button(&self) -> Result<()> { + let locale = self + .adb(&["shell", "getprop", "persist.sys.locale"]) + .await?; + eprintln!("{} runs locale {}", self.serial, locale.trim()); + let deadline = Instant::now() + Duration::from_secs(60); + loop { + // A dump can crash while the screen changes under it, so a failed + // one waits for the next look. + match self.connect_button_bounds().await { + Ok(Some((left, top, right, bottom))) => { + self.adb(&[ + "shell", + "input", + "tap", + &left.midpoint(right).to_string(), + &top.midpoint(bottom).to_string(), + ]) + .await?; + return Ok(()); + } + Ok(None) => {} + Err(err) => eprintln!("{} gave no UI dump, looking again: {err:#}", self.serial), + } + if Instant::now() >= deadline { + bail!( + "the network approval's connect button never showed on {} (locale {locale})", + self.serial + ); + } + sleep(Duration::from_secs(2)).await; + } + } + + /// The bounds of the approval dialog's connect button, or none while the + /// dialog is not up, matched by the localized "Connect" text. + async fn connect_button_bounds(&self) -> Result> { + self.adb(&["shell", "uiautomator", "dump", "/sdcard/connetto-ui.xml"]) + .await?; + let xml = self + .adb(&["shell", "cat", "/sdcard/connetto-ui.xml"]) + .await?; + for node in xml.split(" Result<()> { self.adb(&[ "shell", @@ -929,13 +1396,57 @@ fn parse_bounds(text: &str) -> Option<(i32, i32, i32, i32)> { } } +/// The offer's lines off a page's text, all three present and the port a +/// number, or none while they are not. +fn parse_offer_lines(page: &str) -> Option { + let line = |label: &str| -> Option { + page.lines() + .find_map(|line| line.strip_prefix(label).map(|rest| rest.trim().to_owned())) + }; + let ssid = line("hotspot ssid: ")?; + let passphrase = line("hotspot passphrase: ")?; + let port = line("hotspot port: ")?; + if port == "none" { + return None; + } + Some(HotspotOfferLines { + ssid, + passphrase, + port: port.parse::().ok()?, + }) +} + +/// The ssid a `dumpsys wifi` names as the connected one, absent while it +/// names `` or no line at all. +fn parse_current_ssid(dump: &str) -> Option { + dump.lines().find_map(|line| { + let ssid = line + .strip_prefix("mWifiInfo SSID: \"")? + .split('"') + .next()? + .to_owned(); + (ssid != "" && !ssid.is_empty()).then_some(ssid) + }) +} + +/// The id of the saved network with `ssid` in a `cmd wifi list-networks` +/// listing, the ssid's spaces making the first and last fields the only +/// safe cuts. +fn saved_network_id(listing: &str, ssid: &str) -> Option { + listing.lines().skip(1).find_map(|line| { + let (id, rest) = line.split_once(char::is_whitespace)?; + let (name, _security) = rest.trim_start().rsplit_once(char::is_whitespace)?; + (name.trim() == ssid).then(|| id.to_owned()) + }) +} + #[cfg(test)] mod tests { use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::time::Duration; - use super::Device; + use super::{Device, parse_current_ssid, parse_offer_lines, saved_network_id}; /// A stand-in `adb` that runs `body` with `$state` naming a scratch directory. fn fake_adb(dir: &Path, body: &str) -> PathBuf { @@ -1010,4 +1521,40 @@ echo "adb: device offline" >&2; exit 1"#, assert!(!calls.contains("reconnect"), "{calls}"); assert_eq!(calls.matches("shell true").count(), 1, "{calls}"); } + + #[test] + fn the_offer_lines_are_read_off_the_page() { + let page = "\npeer: listening on 41234\n\ + hotspot ssid: My Hotspot\n\ + hotspot passphrase: s3cr3t pass\n\ + hotspot port: 41234\n\ + hotspot: hosting\n"; + let offer = parse_offer_lines(page).expect("all three lines are there"); + assert_eq!(offer.ssid, "My Hotspot"); + assert_eq!(offer.passphrase, "s3cr3t pass"); + assert_eq!(offer.port, 41234); + assert!(parse_offer_lines("hotspot ssid: only\n").is_none()); + assert!(parse_offer_lines("hotspot port: none\n").is_none()); + } + + #[test] + fn the_connected_ssid_is_read_and_unknown_is_none() { + let dump = "mWifiInfo SSID: \"eduroam\", BSSID: aa:bb:cc:dd:ee:ff\n"; + assert_eq!(parse_current_ssid(dump), Some("eduroam".to_owned())); + let unknown = "mWifiInfo SSID: \"\", BSSID: \n"; + assert_eq!(parse_current_ssid(unknown), None); + assert_eq!(parse_current_ssid("no wifi line\n"), None); + } + + #[test] + fn the_saved_network_id_is_found_by_its_ssid_with_spaces() { + let listing = "Network Id SSID Security type\n\ + 0 Agy Centre Open open\n\ + 1 AmadeusWiFree wpa2-psk\n"; + assert_eq!( + saved_network_id(listing, "Agy Centre Open"), + Some("0".to_owned()) + ); + assert_eq!(saved_network_id(listing, "Absent"), None); + } } diff --git a/crates/connetto-test-harness/src/inspector.rs b/crates/connetto-test-harness/src/inspector.rs index e66cce44..74fd661c 100644 --- a/crates/connetto-test-harness/src/inspector.rs +++ b/crates/connetto-test-harness/src/inspector.rs @@ -243,6 +243,24 @@ impl PageSession { bail!("the page has no button {label:?}") } } + + /// Focus the input named `name` and type `text` into it, trusted input + /// the form's handler treats as the user's. + /// + /// # Errors + /// + /// When the page has no such input, or as [`Self::call`]. + pub async fn set_input(&mut self, name: &str, text: &str) -> Result<()> { + let script = format!( + "(() => {{ const input = document.querySelector('input[name={name:?}]'); if (!input) return false; input.focus(); return true; }})()" + ); + if self.evaluate(&script).await? != true { + bail!("the page has no input named {name:?}") + } + self.call("Input.insertText", serde_json::json!({ "text": text })) + .await?; + Ok(()) + } } /// The next text frame as JSON, before `deadline`. diff --git a/crates/connetto-test-harness/src/stack.rs b/crates/connetto-test-harness/src/stack.rs index fe857e47..a16f0799 100644 --- a/crates/connetto-test-harness/src/stack.rs +++ b/crates/connetto-test-harness/src/stack.rs @@ -241,11 +241,12 @@ pub struct DemoDeviceCa { pub const DEMO_DEVICE_ROOT_VAR: &str = "CONNETTO_DEMO_BUILD_DEVICE_ROOT"; /// The demo's build features for a mobile target, with its device identity -/// when the stack named a deployment root. +/// and the peer link and hotspot it carries when the stack named a +/// deployment root. #[must_use] pub fn demo_mobile_features() -> &'static str { if std::env::var_os(DEMO_DEVICE_ROOT_VAR).is_some() { - "mobile,device-identity" + "mobile,device-identity,peer" } else { "mobile" } diff --git a/crates/connetto-web/Cargo.lock b/crates/connetto-web/Cargo.lock index d07b2c11..ca317176 100644 --- a/crates/connetto-web/Cargo.lock +++ b/crates/connetto-web/Cargo.lock @@ -394,12 +394,15 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "ipnet", "js-sys", "libsqlite3-sys", + "parking_lot", "rustix", "serde", "serde_json", "sha2", + "socket2", "sqlite-diff-rs", "sqlite-wasm-rs", "sqlparser", @@ -819,7 +822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1502,6 +1505,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -1807,6 +1819,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1969,7 +2004,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2095,6 +2130,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.13.1" @@ -2293,7 +2337,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2400,6 +2444,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "seahash" version = "4.1.0" @@ -3483,7 +3533,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/docs/architecture/19-device-to-device.md b/docs/architecture/19-device-to-device.md index 7bab51fc..a0a9787f 100644 --- a/docs/architecture/19-device-to-device.md +++ b/docs/architecture/19-device-to-device.md @@ -48,10 +48,12 @@ The lifetime is application-requested per case under a server ceiling and refuse **Decided (R76).** The data plane is the local network, with discovery by gateway-probe first (a hotspot host IS the gateway, so clients need no multicast to find it) and mDNS as the general case under the service type `_connetto-peer._tcp.local`, then mutual TLS over plain TCP with both sides verifying against the built-in roots and the revocation lists. It is for native targets only, phones, tablets, laptops and desktops alike, since browsers cannot take the listening role on any radio path and keep the server path. The TXT record carries the protocol version and a certificate fingerprint only, never an account identity, and discovery is treated as fully public with authentication happening only at the handshake. -**Built (R76, 2026-10-07), the link.** The `connetto-peer` crate holds the link, and `connetto-client`'s `peer` feature drives it from the enrolment task. A device holding a valid certificate listens at the address `NativeDurable::with_peer_listener` names, `0.0.0.0:0` by default, and dials with `NativeClient::link_peer`. Both sides run TLS 1.3 signed by their device keys, verify each other against the deployment roots, the kept revocation lists, the five-minute window and the accepted attestation levels, and then exchange the lists the other lacks through the same intake the server's pushes use, so a list learned from a peer revokes exactly as one pushed by the server. A link pings every 15 seconds, closes after 45 seconds of silence or when the peer's certificate expires, takes a renewed certificate in a `Certificate` frame, and two devices dialling each other keep one link. A device whose certificate expires, whose clock leaves the window, or whose certificate is withdrawn or revoked closes every link and stops listening. Hosting and the Bluetooth exchange are the next slices. +**Built (R76, 2026-10-07), the link.** The `connetto-peer` crate holds the link, and `connetto-client`'s `peer` feature drives it from the enrolment task. A device holding a valid certificate listens at the address `NativeDurable::with_peer_listener` names, `0.0.0.0:0` by default, and dials with `NativeClient::link_peer`. Both sides run TLS 1.3 signed by their device keys, verify each other against the deployment roots, the kept revocation lists, the five-minute window and the accepted attestation levels, and then exchange the lists the other lacks through the same intake the server's pushes use, so a list learned from a peer revokes exactly as one pushed by the server. A link pings every 15 seconds, closes after 45 seconds of silence or when the peer's certificate expires, takes a renewed certificate in a `Certificate` frame, and two devices dialling each other keep one link. A device whose certificate expires, whose clock leaves the window, or whose certificate is withdrawn or revoked closes every link and stops listening. The Bluetooth exchange is the next slice. **Built (R76, 2026-10-07), discovery.** While a device serves, it advertises `_connetto-peer._tcp.local` over mDNS under a random name, with only the frame version and the SHA-256 of its current certificate in the TXT record, and browses for the others, reporting each as `ClientEvent::PeerFound` and `PeerGone`. Unless the application turns autolink off with `NativeDurable::with_peer_autolink(false)`, the client dials what it finds, retrying an unreachable peer from 5 seconds up to 5 minutes and never redialling a refused, revoked or expired one until it advertises a renewed certificate. At most 8 dials run at once and at most 256 found devices are tracked, so fake advertisements cannot exhaust the device. Android holds a multicast lock while browsing. A device that joins a hotspot through the offer or the text fallback dials the host directly at the gateway, which arrives with the hotspot slice. +**Built (R76, 2026-10-08), hosting and joining on Android.** `NativeClient::host_hotspot` starts Android's local-only hotspot and answers its SSID, passphrase and, while the device serves, its peer port. `join_hotspot` asks the platform for that network, which the user approves, binds only the peer sockets to it so the server connection keeps the phone's usual network, and dials the host at the gateway on the offered port. The hotspot follows the application's calls and the client's life, not the certificate, so an expired host keeps its group's network up. The Kotlin these need ships in `connetto-peer-android`, and every other system answers `Unsupported` until its own slice. On the two A35s the Android 15 phone hosted and the Android 14 phone joined and linked. An A35 started its access point only once its own Wi-Fi was off, measured while that Wi-Fi sat on eduroam's 5 GHz channel 56. + **Decided (R76, 2026-10-02), making a shared network and joining it.** Android, Windows and Linux start a hotspot from the application (Android's local-only hotspot, a Wi-Fi Direct access point on Windows, NetworkManager's hotspot on Linux). macOS cannot host from an application, and an iPhone or iPad hosts only through Personal Hotspot switched on by hand, whose password the user types into the application once. A host announces itself with a public Bluetooth beacon carrying the protocol version and its certificate fingerprint. A joining device connects to it over Bluetooth, both run the same mutual TLS as the Wi-Fi link with the same checks and exchange revocation lists, and only then does the host send the hotspot's name and password, encrypted for that device, as Apple's Wi-Fi password sharing proves identity before sending a password. With Bluetooth off, the host shows a `WIFI:` QR code and the same details as text. Every platform then asks its own system to join. The decision rests on these field facts. A hotspot host is a member of its own network, Android at `192.168.x.1` through `LocalOnlyHotspot`, application-startable with no SIM service, and iOS at `172.20.10.1` through a manual, carrier-gated toggle whose zero-coverage start stays unverified until R80's field test. Android multicast reception needs a `MulticastLock`, and iOS 14 and later and macOS 15 and later prompt once for local-network permission. A university network can put devices in separate subnets that mDNS cannot cross (measured on eduroam), and a battery travel router removes every hotspot caveat. Bluetooth carries only the hotspot handoff. A full Bluetooth data plane is never promised, the crate landscape being pre-1.0 and single-maintainer, and Noise returns to the cipher table only if that plane ever exists. diff --git a/examples/dioxus-desktop-demo/Cargo.lock b/examples/dioxus-desktop-demo/Cargo.lock index 996da75b..14a83076 100644 --- a/examples/dioxus-desktop-demo/Cargo.lock +++ b/examples/dioxus-desktop-demo/Cargo.lock @@ -1160,12 +1160,14 @@ dependencies = [ "chacha20poly1305", "connetto-app-attest", "connetto-core", + "connetto-peer", "connetto-schema", "core-foundation", "diesel", "diesel-sqlite-session", "futures-util", "getrandom 0.3.4", + "ipnet", "jni 0.21.1", "js-sys", "keyring-core", @@ -1173,6 +1175,7 @@ dependencies = [ "linux-keyutils-keyring-store", "oo7", "open", + "parking_lot", "reqwest 0.12.28", "ring", "rmp-serde", @@ -1182,6 +1185,7 @@ dependencies = [ "serde_bytes", "serde_json", "sha2 0.10.9", + "socket2", "sqlite-diff-rs", "sqlite-wasm-rs", "sqlparser", @@ -1252,6 +1256,7 @@ dependencies = [ "connetto-dioxus", "connetto-file-client", "connetto-file-server", + "connetto-peer-android", "connetto-schema", "connetto-server", "connetto-test-harness", @@ -1326,6 +1331,35 @@ dependencies = [ "url", ] +[[package]] +name = "connetto-peer" +version = "0.0.0" +dependencies = [ + "connetto-core", + "mdns-sd", + "parking_lot", + "ring", + "rmp-serde", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_bytes", + "socket2", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "connetto-peer-android" +version = "0.0.0" +dependencies = [ + "manganis", + "thiserror 2.0.20", +] + [[package]] name = "connetto-schema" version = "0.0.0" @@ -2929,6 +2963,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3973,6 +4018,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "image" version = "0.25.10" @@ -4892,6 +4947,21 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "mdns-sd" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "127d3355da0615643c88c885e29a1ccb29ca4b1ca1f2e44b3caed2e728a796aa" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.3" @@ -4986,6 +5056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -7973,6 +8044,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "socket-pktinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.61.2", +] + [[package]] name = "socket2" version = "0.6.5" @@ -8014,6 +8096,9 @@ name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spki" diff --git a/examples/dioxus-desktop-demo/Cargo.toml b/examples/dioxus-desktop-demo/Cargo.toml index 23429446..13558aa7 100644 --- a/examples/dioxus-desktop-demo/Cargo.toml +++ b/examples/dioxus-desktop-demo/Cargo.toml @@ -50,6 +50,9 @@ connetto-dioxus = { path = "../../crates/connetto-dioxus", features = ["desktop" [target.'cfg(target_os = "android")'.dependencies] # Bundles `android/`, the manifest that turns off backup and device transfer. manganis = "0.7.10" +# The peer link's and hotspot's Kotlin plugin, and its Wi-Fi permissions the +# build merges into the app's manifest. +connetto-peer-android = { path = "../../crates/connetto-peer-android" } [features] default = ["desktop"] @@ -58,6 +61,9 @@ mobile = ["dioxus/mobile"] # Enrols this device under the deployment root `CONNETTO_DEMO_BUILD_DEVICE_ROOT` # names at build time, which `connetto-demo-stack` mints (R74 decision 30). device-identity = ["connetto-client/device-identity", "connetto-auth-session/device-identity"] +# The peer link and the hotspot it hosts and joins (R76): the device identity +# the link proves with, and the demo's peer panel. +peer = ["connetto-client/peer"] [dev-dependencies] connetto-test-harness = { path = "../../crates/connetto-test-harness" } diff --git a/examples/dioxus-desktop-demo/android/src/main/AndroidManifest.xml b/examples/dioxus-desktop-demo/android/src/main/AndroidManifest.xml index 16d6e527..0e0b9929 100644 --- a/examples/dioxus-desktop-demo/android/src/main/AndroidManifest.xml +++ b/examples/dioxus-desktop-demo/android/src/main/AndroidManifest.xml @@ -3,8 +3,9 @@ Merged into the demo's generated manifest. It turns off cloud backup and device-to-device transfer, because the replica and the credential records are encrypted with a Keystore key that never leaves the phone, so a copy on - another device cannot be read. It also declares the permission the peer - discovery's multicast lock needs. + another device cannot be read. The peer link's and hotspot's Wi-Fi + permissions live in `connetto-peer-android`'s manifest, which the build + merges in. This module is a stand-in awaiting the proper home in Dioxus. dx 0.7.10 parses but never applies `[android.raw] application_attrs` and @@ -13,7 +14,6 @@ and its declaration in src/main.rs are deleted. --> - diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index c5c5d2fe..8681f115 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -45,6 +45,9 @@ //! root `CONNETTO_DEMO_BUILD_DEVICE_ROOT` names, which the stack mints under //! `target/demo-device-ca` and hands the program it runs, and the proofs turn //! the feature on whenever it is set. +//! +//! With the `peer` feature the demo shows its peer panel (R76): its peer +//! listener, its linked peers, and the hotspot it hosts or joins. //! Its server runs `schema.sql` and `policies.sql`, with `schema.sql`, //! `connetto_file_server::DEPLOYMENT_DDL`, `connetto_server::epoch::EPOCH_DDL`, //! `roles.sql` and `content.sql` applied in that order, and `orders,photos` @@ -633,6 +636,302 @@ enum WipeState { Error(String), } +/// The line a linked peer takes in the panel, its account and the head of +/// its key. +#[cfg(feature = "peer")] +fn linked_line(peer: &connetto_core::device_cert::DeviceIdentity) -> String { + format!("linked: {}", short_identity(peer)) +} + +/// A device identity as `account/key prefix`, what the panel names devices +/// by, its own included. +#[cfg(feature = "peer")] +fn short_identity(identity: &connetto_core::device_cert::DeviceIdentity) -> String { + let key = identity.key().to_string(); + let prefix = key.get(..8).unwrap_or_default(); + format!("{}/{prefix}", identity.account()) +} + +/// The offer's port line, `none` while the device does not serve its peer. +#[cfg(feature = "peer")] +fn port_line(port: Option) -> String { + port.map_or_else(|| "none".to_owned(), |port| port.to_string()) +} + +/// The offer this device hosts, as the lines the panel shows it, the +/// passphrase copied out of the offer before it zeroizes its. +#[cfg(feature = "peer")] +#[derive(Clone, Debug)] +struct OfferLines { + /// The hotspot's ssid. + ssid: String, + /// The hotspot's passphrase. + passphrase: String, + /// The device's peer port, present while it serves it. + port: Option, +} + +/// The peer and hotspot panel (R76 slice 4): the device's peer listener, its +/// linked peers, the hotspot it hosts with the offer shown as plain lines, +/// and the form that joins a hotspot another device hosted. +#[cfg(feature = "peer")] +#[component] +fn PeerPanel(parts: SessionParts) -> Element { + use connetto_client::{HotspotError, HotspotOffer, HotspotSecurity, JoinError}; + + // The listener's port, read again every second, since the listener opens + // beside the pump the way enrolment does. + let mut peer_port: Signal> = use_signal(|| None); + { + let parts = parts.clone(); + use_hook(move || { + spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let port = parts.0.native.peer_address().map(|address| address.port()); + if *peer_port.peek() != port { + peer_port.set(port); + } + } + }) + }); + } + + // One line per linked peer, what the link's events name the peer as. + let peers: Signal> = use_signal(Vec::new); + // The offer this device hosts, as its plain lines, until the hotspot + // stops. + let offer: Signal> = use_signal(|| None); + // The last hotspot or join outcome, `idle` before the first. + let outcome: Signal = use_signal(|| "idle".to_owned()); + // A permission a host or join named as missing, which the panel asks for + // on the UI thread, where the platform's prompt wants to be asked from. + let missing: Signal> = use_signal(|| None); + // The join form, the host's offer read off its lines or typed over. + let mut join_ssid: Signal = use_signal(String::new); + let mut join_passphrase: Signal = use_signal(String::new); + let mut join_port: Signal = use_signal(String::new); + + // The link's and the hotspot's events, which the panel owns. + let client = parts.0.client.clone(); + let event_rx = client.events(); + { + let mut peers = peers; + let mut offer = offer; + let mut outcome = outcome; + use_hook(move || { + spawn(async move { + let mut rx = event_rx; + while let Ok(event) = rx.recv().await { + match event { + ClientEvent::PeerLinked { peer } => { + let line = linked_line(&peer); + let mut held = peers.peek().clone(); + if !held + .iter() + .any(|held_line| held_line.as_str() == line.as_str()) + { + held.push(line); + peers.set(held); + } + } + ClientEvent::PeerUnlinked { peer, .. } => { + let line = linked_line(&peer); + let mut held = peers.peek().clone(); + held.retain(|held_line| held_line.as_str() != line.as_str()); + peers.set(held); + } + ClientEvent::HotspotStopped => { + offer.set(None); + outcome.set("stopped by the system or the user".to_owned()); + } + ClientEvent::HotspotLeft => { + outcome.set("the joined network was lost".to_owned()); + } + _ => {} + } + } + }) + }); + } + + // The platform's permission prompt wants the UI thread, so the panel + // fires it there when a host or join named a missing permission. + let mut missing_effect = missing; + use_effect(move || { + if missing_effect().is_some() { + #[cfg(target_os = "android")] + connetto_peer_android::request_peer_permissions(); + missing_effect.set(None); + } + }); + + let host_parts = parts.clone(); + let host_offer = offer; + let host_outcome = outcome; + let host_missing = missing; + let stop_parts = parts.clone(); + let stop_offer = offer; + let stop_outcome = outcome; + let join_parts = parts.clone(); + let mut join_outcome = outcome; + let join_missing = missing; + let leave_parts = parts.clone(); + let leave_outcome = outcome; + + let me = parts + .0 + .native + .device_certificate() + .map(|certificate| short_identity(certificate.identity())); + let peer_line = match (peer_port(), me) { + (Some(port), Some(me)) => format!("peer: listening on {port} as {me}"), + _ => "peer: not serving".to_owned(), + }; + let peers = peers(); + let offer = offer(); + let outcome = outcome(); + + rsx! { + div { + style: "font-family: monospace; font-size: 0.85em; color: #333; border: 1px solid #ccc; border-radius: 4px; padding: 8px 12px; margin-bottom: 12px; line-height: 1.5;", + p { {peer_line} } + for line in peers.iter().cloned() { + p { {line} } + } + if let Some(offer) = offer { + p { "hotspot ssid: " {offer.ssid.clone()} } + p { "hotspot passphrase: " {offer.passphrase.clone()} } + p { "hotspot port: " {port_line(offer.port)} } + } + p { "hotspot: " {outcome} } + button { + onclick: move |_| { + let parts = host_parts.clone(); + let mut offer = host_offer; + let mut outcome = host_outcome; + let mut missing = host_missing; + spawn(async move { + outcome.set("asking for the hotspot".to_owned()); + let result = parts.0.native.host_hotspot().await; + match result { + Ok(details) => { + offer.set(Some(OfferLines { + ssid: details.ssid.clone(), + passphrase: details.passphrase.clone(), + port: details.port, + })); + outcome.set("hosting".to_owned()); + } + Err(err) => { + if let HotspotError::MissingPermission(name) = &err { + missing.set(Some(name.clone())); + } + outcome.set(err.to_string()); + } + } + }); + }, + "Host a hotspot" + } + button { + onclick: move |_| { + let parts = stop_parts.clone(); + let mut offer = stop_offer; + let mut outcome = stop_outcome; + spawn(async move { + parts.0.native.stop_hotspot().await; + offer.set(None); + outcome.set("stopped".to_owned()); + }); + }, + "Stop the hotspot" + } + input { + r#type: "text", + name: "ssid", + placeholder: "the offer's ssid", + value: "{join_ssid}", + oninput: move |event| join_ssid.set(event.value()), + style: "font-family: monospace;", + } + input { + r#type: "text", + name: "passphrase", + placeholder: "the offer's passphrase", + value: "{join_passphrase}", + oninput: move |event| join_passphrase.set(event.value()), + style: "font-family: monospace;", + } + input { + r#type: "text", + name: "port", + placeholder: "the offer's port", + value: "{join_port}", + oninput: move |event| join_port.set(event.value()), + style: "font-family: monospace;", + } + button { + onclick: move |_| { + let ssid = join_ssid.peek().trim().to_owned(); + let passphrase = join_passphrase.peek().clone(); + let port_text = join_port.peek().trim().to_owned(); + if ssid.is_empty() || passphrase.is_empty() { + join_outcome.set("the ssid and the passphrase need filling".to_owned()); + return; + } + let port = match port_text.as_str() { + "" => None, + text => match text.parse::() { + Ok(port) => Some(port), + Err(_) => { + join_outcome.set("the port is not a number".to_owned()); + return; + } + }, + }; + let offer = HotspotOffer::new( + ssid, + passphrase, + HotspotSecurity::Wpa2, + port, + ); + let parts = join_parts.clone(); + let mut outcome = join_outcome; + let mut missing = join_missing; + spawn(async move { + outcome.set("joining the hotspot".to_owned()); + let gateway = parts.0.native.join_hotspot(&offer).await; + match gateway { + Ok(gateway) => { + outcome.set(format!("joined through the gateway {gateway}")) + } + Err(err) => { + if let JoinError::MissingPermission(name) = &err { + missing.set(Some(name.clone())); + } + outcome.set(err.to_string()); + } + } + }); + }, + "Join the hotspot" + } + button { + onclick: move |_| { + let parts = leave_parts.clone(); + let mut outcome = leave_outcome; + spawn(async move { + parts.0.native.leave_hotspot().await; + outcome.set("left".to_owned()); + }); + }, + "Leave the hotspot" + } + } + } +} + #[component] fn App() -> Element { let client = use_context::>(); @@ -736,6 +1035,11 @@ fn App() -> Element { let grouped_text = grouped_label(&counts_by_quantity.value().read()); let grouped_error = counts_by_quantity.error().read().clone(); let pid = std::process::id(); + // The peer and hotspot panel, on the builds that carry the peer link. + #[cfg(feature = "peer")] + let peer_panel: Option = Some(rsx! { PeerPanel { parts: parts.clone() } }); + #[cfg(not(feature = "peer"))] + let peer_panel: Option = None; let device_line = match clock() { Some(clock) => clock.to_owned(), None => device(), @@ -765,6 +1069,9 @@ fn App() -> Element { style: "font-family: monospace; font-size: 0.85em; color: #555; margin: 0 0 8px 0;", "device: " {device_line} } + if let Some(peer_panel) = peer_panel { + {peer_panel} + } if gate() != "open" { button { onclick: move |_| { diff --git a/examples/dioxus-web-demo/Cargo.lock b/examples/dioxus-web-demo/Cargo.lock index c0a7d1cd..2940feb4 100644 --- a/examples/dioxus-web-demo/Cargo.lock +++ b/examples/dioxus-web-demo/Cargo.lock @@ -528,12 +528,15 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "ipnet", "js-sys", "libsqlite3-sys", + "parking_lot", "rustix", "serde", "serde_json", "sha2", + "socket2", "sqlite-diff-rs", "sqlite-wasm-rs", "sqlparser", diff --git a/examples/wasm-smoke/Cargo.lock b/examples/wasm-smoke/Cargo.lock index 1bc9e7b2..c7e1eec9 100644 --- a/examples/wasm-smoke/Cargo.lock +++ b/examples/wasm-smoke/Cargo.lock @@ -394,12 +394,15 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "ipnet", "js-sys", "libsqlite3-sys", + "parking_lot", "rustix", "serde", "serde_json", "sha2", + "socket2", "sqlite-diff-rs", "sqlite-wasm-rs", "sqlparser", @@ -859,7 +862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1584,6 +1587,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -1889,6 +1901,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2064,7 +2099,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2190,6 +2225,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.13.1" @@ -2388,7 +2432,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2495,6 +2539,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "seahash" version = "4.1.0" @@ -2951,10 +3001,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3600,7 +3650,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/examples/webauthn-unlock/Cargo.lock b/examples/webauthn-unlock/Cargo.lock index 034fbd14..9b62b31d 100644 --- a/examples/webauthn-unlock/Cargo.lock +++ b/examples/webauthn-unlock/Cargo.lock @@ -429,12 +429,15 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "ipnet", "js-sys", "libsqlite3-sys", + "parking_lot", "rustix", "serde", "serde_json", "sha2", + "socket2", "sqlite-diff-rs", "sqlite-wasm-rs", "sqlparser", @@ -871,7 +874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1567,6 +1570,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -1869,6 +1881,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2031,7 +2066,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2157,6 +2192,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.13.1" @@ -2355,7 +2399,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2462,6 +2506,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "seahash" version = "4.1.0" @@ -3543,7 +3593,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/examples/yew-web-demo/Cargo.lock b/examples/yew-web-demo/Cargo.lock index a8633bcb..48a5da9f 100644 --- a/examples/yew-web-demo/Cargo.lock +++ b/examples/yew-web-demo/Cargo.lock @@ -403,12 +403,15 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "ipnet", "js-sys", "libsqlite3-sys", + "parking_lot", "rustix", "serde", "serde_json", "sha2", + "socket2", "sqlite-diff-rs", "sqlite-wasm-rs", "sqlparser", @@ -887,7 +890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1852,6 +1855,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -2167,6 +2179,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2417,7 +2452,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2543,6 +2578,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.13.1" @@ -2741,7 +2785,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2848,6 +2892,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "seahash" version = "4.1.0" @@ -3325,10 +3375,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4028,7 +4078,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index 94ba80a1..6f7ad2b8 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -273,7 +273,7 @@ Execution order and nothing else. Status, blockers, landing dates and what each | R72 clock discipline (X6) | NOT STARTED | nothing | no | | R74 device identity and certificates | IN PROGRESS, merged (2026-10-06, #130), and step 7's peer half with proofs 3, 4 and 6's peer half built by R76's slices 1 and 2 (2026-10-07) | an iPhone run for proof 5's Apple half, the IANA PEN before a real deployment | no | | R75 the per-device applied frontier | NOT STARTED | R74. Touches the R2 watermark contract and the R56 import | no | -| R76 the peer link | IN PROGRESS, sliced into pull requests 2026-10-06 (its section's **Slices**), the Android slices first, slices 1 to 3 built 2026-10-07 | nothing for slices 1 to 7, other machines for slice 8 and the home run | no | +| R76 the peer link | IN PROGRESS, sliced into pull requests 2026-10-06 (its section's **Slices**), the Android slices first, slices 1 to 3 built 2026-10-07 and slice 4 on 2026-10-08 | nothing for slices 1 to 7, other machines for slice 8 and the home run | no | | R77 the peer exchange and provisional tier | NOT STARTED | R75 and R76. The retraction's reason rides on R57 step 8's fix | no | | R78 courier recovery | NOT STARTED | R75 and R77 | no | | R79 media over the peer link | NOT STARTED | R77, and R64 to R67 for the chunk machinery | no | @@ -5279,7 +5279,7 @@ A write applied through any path is refused by every other path, proven three wa ## R76: the peer link -**Status.** IN PROGRESS, its hosting, joining, Bluetooth and proof questions decided with the maintainer on 2026-10-02 and the link's on 2026-10-06 (below). Slices 1 to 3, the `connetto-peer` crate, the client's peer link and discovery, are built (2026-10-07). +**Status.** IN PROGRESS, its hosting, joining, Bluetooth and proof questions decided with the maintainer on 2026-10-02 and the link's on 2026-10-06 (below). Slices 1 to 3, the `connetto-peer` crate, the client's peer link and discovery, are built (2026-10-07), and slice 4, Android hosting and joining, on 2026-10-08. **Blocked on** R74. @@ -5292,7 +5292,7 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 1. **Android, Windows and Linux start a hotspot from the application, and Apple devices join.** Android uses `startLocalOnlyHotspot`, Windows a Wi-Fi Direct advertisement in legacy mode, which ordinary devices join as an access point, and Linux NetworkManager's hotspot. macOS has no application API to host (`startIBSSMode` is deprecated since macOS 11), and iPhone and iPad host only through Personal Hotspot switched on in Settings, whose password the user types into the application once so the beacon can hand it out. Android as the only application-started host was rejected, since a group of laptops and iPhones would then need a travel router. 2. **A Bluetooth beacon hands out the hotspot details, with a QR code and text as fallback.** Every machine in the set can scan and connect over Bluetooth from Rust through btleplug, which a desktop without a camera needs. With Bluetooth off, the host shows a `WIFI:` QR code and the same details as text. A QR code and text alone were rejected, since a desktop without a camera would type a long random password after every hotspot restart, and so were prepared shared networks alone. 3. **The beacon is public, and each joining device gets the password through a short exchange in which both sides prove their identity.** The beacon carries the protocol version and the host certificate's fingerprint, as the mDNS TXT record does. A joining device connects to the host over Bluetooth, the two run the same mutual TLS as the Wi-Fi link with the same certificate and revocation-list checks, exchange lists, and only then does the host send `HotspotOffer { ssid, passphrase, security, bssid }`. Apple's Wi-Fi password sharing has the same shape, an identity proof over Bluetooth before the password is sent. A revoked or foreign device is refused. A beacon key the server gives every device was rejected, since a lost device keeps reading beacons until a new key reaches the others online, and so was an unencrypted password in the beacon, which lets any stranger fill and flood the hotspot. -4. **An unattended run on emi and a recorded home run prove it.** On emi, from `main` on a schedule and never from a pull request, one A35's application starts the hotspot and the other A35 joins through the Android shell (`cmd wifi connect-network`), with permissions granted by adb. Before each peer phase closes, the maintainer runs a recorded session at home across every local machine (pippo on Linux, the Mac, the Windows machine, the iPhone, the iPad and the Galaxy M52), with Windows and Linux each hosting once. Manual home runs alone were rejected, and so was emi as a GitHub self-hosted runner, which GitHub advises against for public repositories. +4. **An unattended run on emi and a recorded home run prove it.** On emi, from `main` on a schedule and never from a pull request, one A35's application starts the hotspot and the other A35's application joins it, the runner tapping the system's approval (decision 16), with permissions granted by adb. Before each peer phase closes, the maintainer runs a recorded session at home across every local machine (pippo on Linux, the Mac, the Windows machine, the iPhone, the iPad and the Galaxy M52), with Windows and Linux each hosting once. Manual home runs alone were rejected, and so was emi as a GitHub self-hosted runner, which GitHub advises against for public repositories. ### Decisions, taken with the maintainer on 2026-10-06 @@ -5302,18 +5302,52 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 8. **A peer refuses a certificate whose attestation level is outside its own accepted set.** `NativeDurable::with_peer_accepted_attestation` takes the set, all three levels by default as on the server (R74 decision 33), and a refused peer closes with `AttestationRefused`. A set narrowed in an application update then takes effect at once. Relying on the server's enrolment gate alone was rejected, since certificates issued before an operator narrows the set stay valid for up to the ceiling. 9. **A certificate's expiry reaches every live link on time, at both ends** (2026-10-07). The device's task wakes at its own `not_after` plus the five-minute tolerance, and when a `ClockOff` certificate's window opens, so the table's standing rows apply when the standing changes. Each link closes at its peer's `not_after` plus the tolerance with `PeerExpired`, which a modified peer that never closes its own links cannot escape. Both are monotonic sleeps up to a wall-clock deadline, checked again against the wall clock when they fire, and a jumped clock is caught by the next look. Applying either only at the hourly look was rejected, since a link would outlive an expiry by up to an hour, and so was leaving a peer's expiry to that peer. 10. **A renewed certificate travels to every live link in a `Certificate` frame** (2026-10-07). After a grant the device sends `Certificate { leaf, issuer }` on every link, and the far end verifies the chain exactly as at a handshake, requires the same device key the handshake proved, and moves that link's expiry deadline. A chain the verifier refuses closes the link, with `PeerRevoked` for a revoked chain, `PeerExpired` for an expired one, and `Protocol` for any other refusal or another key. Ending every link at the old certificate's expiry was rejected, since each link would break once per certificate lifetime while both devices stayed valid. -11. **The gateway probe dials the port the join handed over** (2026-10-07). A device that joined a hotspot through the Bluetooth `HotspotOffer`, the text fallback or the join API learns the host's peer port with the network and dials the gateway at that port at once, so decision 6's system-picked port stays. Every other network relies on mDNS, whose SRV record carries the port. A fixed default port was rejected, since a second client on the host machine would be reachable only by mDNS and decision 6 would change, and so was dropping the probe, which leaves a hotspot whose multicast is filtered with no path. +11. **The gateway probe dials the port the join handed over** (2026-10-07). A device that joined a hotspot through the Bluetooth `HotspotOffer`, the text fallback or the join API learns the host's peer port with the network and dials the gateway at that port at once, so decision 6's system-picked port stays. Every other network relies on mDNS, whose SRV record carries the port. A fixed default port was rejected, since a second client on the host machine would be reachable only by mDNS and decision 6 would change, and so was dropping the probe, which leaves a hotspot whose multicast is filtered with no path. A failed dial is redialed every 2 seconds while the network stays joined and no link to the gateway stands, for at most 60 seconds after the join, then `ClientEvent::JoinedHostUnreached` reports it (decided with the maintainer 2026-10-11). iOS refuses an application's first connection to a local address with `EHOSTUNREACH` while it asks the user for local-network access, so a single dial never links a fresh install. A dial made only before the join was rejected, since iOS asks only once a local address is dialed. 12. **Discovery reports every peer it finds, and the client dials them itself unless the application turns that off** (2026-10-07). While the device serves, discovery advertises and browses, and reports `ClientEvent::PeerFound { address, fingerprint }` and `PeerGone { fingerprint }`. `NativeDurable::with_peer_autolink(bool)`, on by default, lets the client dial what it finds under the table below, which keeps every application from rebuilding the backoff and refusal rules. With autolink off the application dials with `link_peer`, for a picker the user confirms, for linking only while a screen is open, or for not revealing the device to every certified device nearby. Inbound links are accepted either way, since the listener verifies every peer. Reporting peers and leaving every dial to the application was rejected, since each application would rebuild the retry rules and a careless one would hammer refused peers. 13. **Discovery bounds what an unauthenticated network can make it do** (2026-10-07). At most 8 dials run at once and a due dial waits its turn, so every row still dials, and at most 256 instances are tracked, a find beyond that evicting the oldest instance not Linked without an event. A dial holds its slot until its outcome arrives, whatever happens to its instance meanwhile, and queued dials start oldest first. The listener runs at most 16 inbound handshakes at once and closes a connection beyond that at accept, and a failed accept waits 100 ms before the next. mDNS lets any process on the network advertise, and a few hundred fake instances each holding a dial's socket for the 10-second handshake bound would exhaust an Android application's file descriptors, its server connection included. Leaving the instance table unbounded was rejected, since memory and `PeerFound` events would still grow with fakes until their TTL runs out. +14. **A hosted hotspot follows the client and the application, independent of the certificate** (2026-10-08). The expected use is a group with no shared network, a camp or a field site, where the host is mostly the group's network provider and every device links to every other through discovery. `host_hotspot` therefore needs no valid certificate, the `HotspotOffer` carries the peer port only while the device serves, and the hotspot stops on `stop_hotspot`, on the client's close, or when the system or the user stops it. An expired host keeps the group's network up, and the Bluetooth exchange still hands out the password only after both sides prove their identity (decision 3). Tying the hotspot to the host's standing was rejected, since every device linked through it would lose its network when the host's certificate ended. +15. **A device joined to a hotspot binds only its peer sockets to that network** (2026-10-08). Android gives a network requested through a `WifiNetworkSpecifier` to the requesting application alone and routes nothing to it unless the application binds, and an A35 supports no two concurrent Wi-Fi connections, measured, so the joined phone also loses its usual Wi-Fi. The link's dial and listen sockets and the mDNS sockets bound for the joined network go through `Network.bindSocket`, and the server connection keeps the default network, mobile data on an A35. Binding the whole process was rejected, since the server would then be unreachable while joined even with mobile data on. +16. **The emi proof joins the second phone through the application** (2026-10-08). The runner reads the host's offer from its page, types it into the joiner's form, taps the system's approval through uiautomator, and asserts that the joiner reaches the host at the gateway and both report the link, so `requestNetwork`, the socket binding and the gateway probe run on hardware. Joining through the shell alone was rejected, since the application's own join path, the riskiest part, would wait for a human run. +17. **Each system needing foreign code or `unsafe` gets its own small crate, and `connetto-client` owns the rules** (2026-10-08). `connetto-client` holds the hotspot API, the backend trait each system implements, and the tables. Android's backend needs Kotlin compiled into the application, since `LocalOnlyHotspotCallback` and `NetworkCallback` are abstract classes, so `connetto-peer-android` ships the plugin through manganis, and the client drives it by class name over `JavaAccess` (R74 decision 20). Apple's and Windows's backends need `unsafe` and arrive in slice 8 as `connetto-peer-apple` and `connetto-peer-windows`, as App Attest lives in `connetto-app-attest`. Linux's NetworkManager backend is safe Rust over zbus and stays in `connetto-peer`. This narrows decision 5, which placed every platform dependency in `connetto-peer`. One crate for every platform's glue was rejected, since it would allow `unsafe` for every platform at once, and so was the Android plugin inside `connetto-auth-session`, which would fit Android alone. ### Design - **The Bluetooth exchange.** The host serves through the platform's server role (`BluetoothGattServer` on Android, `GattServiceProvider` on Windows, BlueZ through `bluer` on Linux, `CBPeripheralManager` in the foreground on iPhone and iPad), Android's and Apple's in platform plugins on the `connetto-auth-session` pattern. The joiner writes to one characteristic and reads notifications on another. Frames are length-prefixed and split to the negotiated packet size, and rustls runs over that stream through `read_tls` and `write_tls`, which take any reader and writer. An A35 supports 16 advertising sets and 1,650 bytes of advertising data, measured. -- **Joining.** iPhone and iPad join through `NEHotspotConfigurationManager`, which the user confirms. The Mac uses `CWInterface.associate`, which may ask for an administrator password. Windows saves a profile and calls `WlanConnect`. Linux calls NetworkManager's `AddAndActivateConnection`, and pippo's NetworkManager answers `auth` for `network-control` outside a desktop session, measured. Android uses the platform's network request with the user's approval in the application, and the shell in tests. +- **Joining.** iPhone and iPad join through `NEHotspotConfigurationManager`, which the user confirms. The Mac uses `CWInterface.associate`, which may ask for an administrator password. Windows saves a profile and calls `WlanConnect`. Linux calls NetworkManager's `AddAndActivateConnection`, and pippo's NetworkManager answers `auth` for `network-control` outside a desktop session, measured. Android uses the platform's network request with the user's approval in the application, in tests too, with the approval tapped by the runner (decision 16). - **Hosting permissions.** Android needs `CHANGE_WIFI_STATE`, `NEARBY_WIFI_DEVICES`, `BLUETOOTH_ADVERTISE` and `BLUETOOTH_CONNECT`. pippo's NetworkManager answers `no` to `wifi.share.protected` outside a desktop session, measured, so a refused hotspot maps to a typed error naming the permission. A host keeps its hotspot credentials in memory only, for the life of the hotspot. - **Revocation lists on the link.** Each side sends its CRL Numbers per issuer as the first frames inside the established TLS session and then any list the other lacks (R74). A live link whose peer the new list revokes closes with a typed reason. - **Discovery networks.** emi's eduroam put the two A35s in separate subnets, measured, so they reach each other by direct connection and never by mDNS. Devices on such a network meet through a hotspot. +- **Hotspot.** The application calls `NativeClient::host_hotspot()`, answered by a `HotspotOffer { ssid, passphrase, security, port }` or a `HotspotError` (`Unsupported`, `MissingPermission(name)`, `Incompatible`, `NoChannel`, `Disallowed`, `TimedOut`, `Failed`), and `stop_hotspot()`. A joiner calls `join_hotspot(offer)`, answered by the joined network's gateway or a `JoinError` (`Unsupported`, `MissingPermission(name)`, `Declined`, `TimedOut`, `Failed`), and `leave_hotspot()`. `ClientEvent::HotspotStopped` and `HotspotLeft` report what the system ended. Android needs `CHANGE_WIFI_STATE`, `NEARBY_WIFI_DEVICES` (with `neverForLocation`), `ACCESS_WIFI_STATE`, `CHANGE_NETWORK_STATE` and `ACCESS_NETWORK_STATE`, which `connetto-peer-android`'s manifest declares, and the application asks the user for `NEARBY_WIFI_DEVICES` at run time, which the plugin offers to do. A system whose backend has not arrived answers `Unsupported` to every call, Linux, Windows and the Apple systems until slice 8. Every wait is bounded, 30 seconds for a hotspot to start and 60 seconds for a join including the user's approval. + +The hosting side against its state, beside the link's and discovery's tables. Nothing in it depends on the device's standing (decision 14). + +| Event | Off | Starting | Hosting | +| --- | --- | --- | --- | +| `host_hotspot` | a missing permission answers `MissingPermission`, else request the hotspot, become Starting | wait on the same request | answer the current offer | +| Hotspot started | not applicable | become Hosting, answer the offer, its port present while the device serves | not applicable | +| Hotspot failed | not applicable | become Off, answer the mapped `HotspotError` | not applicable | +| 30 seconds without an answer | not applicable | cancel, become Off, answer `TimedOut` | not applicable | +| System or user stops it | not applicable | become Off, answer `Failed` | become Off, emit `HotspotStopped` | +| `stop_hotspot` | nothing | cancel, become Off, the pending call answers `Failed` | close the reservation, become Off | +| Standing changes | nothing | nothing | nothing, a later `host_hotspot` answers the port as the standing then has it | +| Client closed | nothing | cancel | close the reservation | + +The joining side against its state. + +| Event | Idle | Requesting | Joined | +| --- | --- | --- | --- | +| `join_hotspot(offer)` | a missing permission answers `MissingPermission`, else request the network for the offer's SSID and passphrase, become Requesting | cancel the earlier request, request this one | release the joined network, request this one | +| Network available | not applicable | become Joined, bind peer sockets for its subnet (decision 15), read its gateway, answer it, and with autolink dial the gateway at the offer's port, discovery taking over after | ignore | +| The dial to the gateway fails | not applicable | not applicable | dial again 2 seconds later while no link to the gateway stands, and 60 seconds after the join stop and emit `JoinedHostUnreached` | +| A link to the gateway stands | not applicable | not applicable | stop dialing | +| Network unavailable, the user declined or the request timed out | not applicable | become Idle, answer `Declined` or `TimedOut` | not applicable | +| 60 seconds without an answer | not applicable | cancel, become Idle, answer `TimedOut` | not applicable | +| Network lost | not applicable | not applicable | stop dialing and binding, become Idle, emit `HotspotLeft`, the links over it close by liveness | +| `leave_hotspot` | nothing | cancel, become Idle, the pending call answers `Declined` | release the network, stop dialing and binding, become Idle | +| Standing changes | nothing | nothing | nothing | +| Client closed | nothing | cancel | stop dialing, release the network | + - **Discovery.** `mdns-sd` advertises `_connetto-peer._tcp.local` under a random instance name drawn at each registration, at the bound port, with a TXT record of `v`, the frame version, and `fp`, the hex SHA-256 of the presented leaf, so the instance and fingerprint change at every renewal and nothing in it names an account. A found instance is dialled at each resolved address, IPv4 first, until one answers. On Android the client holds a `WifiManager.MulticastLock` while it browses, taken through the application's `JavaAccess`. Discovery against the device's standing and the found instance's state, beside the link's table above. `Serving` is the link's `Serving`, `Idle` is every other standing. An instance is `New` when found, `Linked` while a live link carries its fingerprint, `Waiting` while a retry is pending, and `Barred` after a refusal until its fingerprint changes. Autolink off removes every dial and retry from the table and keeps the rest. @@ -5385,7 +5419,7 @@ One pull request each, in this order, each green and useful on its own (2026-10- 1. **The `connetto-peer` link on loopback** (step 2, decisions 5, 7 and 8). **Built (R76, 2026-10-07)** as `crates/connetto-peer`, proven by its seventeen loopback tests. The crate alone, its verifiers, frames, list forwarding, ping and duplicate rule, taking its certificate, key, roots, lists and clock as inputs. Proven in CI on loopback with certificates minted in the test, which carries R74's proof 4 and proof 6's refusal with a clock set outside the window. 2. **The client's peer link** (R74 step 7's peer half, decision 6). **Built (R76, 2026-10-07)** behind `connetto-client`'s `peer` feature, with decisions 9 and 10 in `connetto-peer`, proven by the enrolment unit tests and by `a_reported_device_is_refused_by_a_peer_that_learned_it_from_a_peer` against the real server. The `peer` feature, the builder setters, the table above against the enrolment task's standing, the events, and received lists through the one intake. Proven in CI against the real server, which carries R74's proof 3, three clients and a server stopped before the reported device dials. 3. **Discovery** (step 1, decisions 12 and 13). **Built (R76, 2026-10-07)** in `connetto-peer`'s `discovery` feature, which `connetto-client`'s `peer` feature turns on. `mdns-sd` under `_connetto-peer._tcp.local`, the TXT record carrying the frame version and the leaf fingerprint only, `PeerFound` and `PeerGone`, autolink under the discovery table, and Android's `MulticastLock`. Proven on loopback in CI. The gateway probe of decision 11 arrives with slice 4's join API, which knows the gateway and the port, and the A35 leg of discovery runs over slice 4's hotspot, since emi's eduroam separates the phones. -4. **Android hosting and joining** (step 3's Android part and step 6's Wi-Fi permissions). `startLocalOnlyHotspot` from the application through a platform plugin on the `connetto-auth-session` pattern, the typed refusal naming a missing permission, joining through the platform's network request, and the demo showing its peers. Proven on emi, one A35 hosting and the other joining through `cmd wifi connect-network`. +4. **Android hosting and joining** (step 3's Android part and step 6's Wi-Fi permissions, decisions 11 and 14 to 17). `startLocalOnlyHotspot` and the joining network request through `connetto-peer-android`'s Kotlin plugin, the hotspot tables, the per-socket binding, the gateway probe, the typed refusals naming a missing permission, and the demo hosting, joining and showing its peers. Proven on emi, one A35 hosting and the other joining through its application, the runner tapping the approval (decision 16). **Built (R76, 2026-10-08)**, the two-phone run green on emi, the Android 15 A35 hosting and the Android 14 one joining, both `chip-proven`, the joiner reaching the host at the gateway. Measured on the way: an A35 refuses to start an access point while its own Wi-Fi sits on eduroam's 5 GHz channel 56, hostapd failing on its beacon on both phones, and starts it once its Wi-Fi is off, so the run turns the host's Wi-Fi off first, as a host in the field has no network. Whether mDNS works on a joined network without binding its sockets, which `mdns-sd` offers no way to do, is not measured yet, since the gateway dial links the two first. 5. **The Bluetooth beacon and exchange on Android** (step 4's Android part and step 6's Bluetooth permissions). `BluetoothGattServer` as host, the joiner over btleplug, rustls over the framed characteristics, and `HotspotOffer` only after both sides proved their identity. Proven on emi between the two A35s. 6. **The text and QR fallback** (step 5). The `WIFI:` payload and its text form, the text path proven on emi and the camera scan left to the home run. 7. **The unattended emi run** (step 7's first half). A schedule on emi running slices 4 to 6 from `main`, never from a pull request, with its evidence kept on emi.