//! The guest side of Wispers Access, shared by every client app: joining and //! restoring shares over any transport, the local share store, the guest API //! calls, and the loopback HTTP proxies. //! //! The entry point is [`Client`], one per data directory. The wire types an //! integrator meets ([`SharedApp`], [`Transport`]) are re-exported from the wire //! crate, so the SDK is the only dependency an app needs. The same surface //! is what UniFFI turns into the Swift and Kotlin bindings. The SDK logs //! through `tracing`; a Rust app installs its own subscriber, the bindings //! get [`install_log_sink`]. uniffi::setup_scaffolding!(); mod certificates; mod forward_proxy; mod guest_node; mod iroh_transport; mod logging; mod loopback; mod loopback_forwarding; mod pairing; mod reverse_proxy; mod secrets; mod socket_owner; mod storage; #[cfg(test)] mod test_support; mod transports; mod wispers_connect_transport; pub use certificates::{CertificateDescription, CertificateKind}; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use certificates::{OriginTrust, TrustVerdict}; pub use logging::{LogLevel, LogSink, install_log_sink}; pub use loopback::RequiredCookie; pub use secrets::{FileSecretStore, SecretScope, SecretStore, SecretStoreError}; pub use storage::ShareId; pub use wispers_access_wire::{AppKind, AppProxyMode, SharedApp, Transport}; use guest_node::GuestNode; use std::collections::HashMap; use std::future::Future; use std::path::PathBuf; use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; use tokio::sync::OnceCell; use transports::TerminalState; use wispers_access_wire as wire; pub type Result = std::result::Result; /// What can go wrong at the SDK's surface. Flat on purpose: the bindings /// carry the variant and the message, and an app decides by the variant. #[derive(Debug, thiserror::Error, uniffi::Error)] #[uniffi(flat_error)] pub enum SdkError { #[error("invalid invite code: {0}")] InvalidInvite(String), /// The share turned a well-formed invite down, because it's used, expired /// or unknown. The message is the description to show to the user. #[error("{0}")] InviteRefused(String), #[error("no share {0}")] NoSuchShare(String), /// The store or the secret store failed. #[error("{0}")] Storage(String), /// The host node, or the hub in front of it, could not be reached or /// refused. Includes the transport's own bookkeeping on the way. #[error("{0}")] HostNode(String), /// The host node runs a newer Wispers Access than this SDK understands /// and turned the request away. The user should update the app. #[error("{0}")] ClientTooOld(String), /// A port that one of the share's loopback origins needs is taken, by /// another share's open app or by another process. The message, a /// sentence to show the user, says which. #[error("{0}")] PortInUse(String), #[error("{0}")] Internal(String), } impl SdkError { fn storage(e: impl Into) -> Self { SdkError::Storage(format!("{:#}", e.into())) } fn host_node(e: impl Into) -> Self { SdkError::HostNode(format!("{:#}", e.into())) } } impl From for SdkError { fn from(e: SecretStoreError) -> Self { SdkError::Storage(e.to_string()) } } #[derive(uniffi::Record)] pub struct ClientConfig { /// Where the client keeps its state. Created if missing. pub data_dir: String, /// Where key material goes. Without one, a [`FileSecretStore`] under /// `data_dir`. pub secrets: Option>, /// Told whenever a stored share changes. Without one, nobody is. pub observer: Option>, } /// What an app implements to follow its shares without polling. #[uniffi::export(with_foreign)] pub trait Observer: Send + Sync { /// The stored share changed: it was joined, its name or apps changed, /// its host node turned this device away for good, or it needs a newer /// app (or stopped needing one). Called from the client's runtime, for /// `leave` never (the caller knows). fn on_share_changed(&self, share: Share); } /// This device's view of its shares: the store, the runtime everything runs /// on, and one guest node per share once it has been dialled. Cheap to clone; /// all clones are the same client. #[derive(Clone, uniffi::Object)] pub struct Client { inner: Arc, } struct ClientInner { db: Arc, secrets: Arc, observer: Arc, runtime: tokio::runtime::Handle, /// Set when the client started the runtime itself, so it can stop it. owned_runtime: Mutex>, /// Guest nodes by share id, each restored on first use. guest_nodes: Mutex>>>>, } /// A share as this device knows it: what the host node last said it is, and /// whether it still lets this device in. #[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] pub struct Share { pub id: ShareId, /// The share's name, as the host node reports it. pub name: String, /// The `` in `http://..wa.localhost`. Unique per device, /// derived from the name at join. pub label: String, pub transport: Transport, /// The apps as last fetched from the host node, in its order. pub apps: Vec, /// Certificates for the share's `https` origins, one PEM certificate each, /// as last fetched from the host node, for the guest's user to install on /// the device. pub ca_certificates: Vec, pub state: ShareState, /// When this device joined. pub joined_at: std::time::SystemTime, } #[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] pub enum ShareState { Live, /// The host node runs a newer Wispers Access than this SDK understands /// and no longer serves it. The apps are not usable until the user /// updates the app. Outdated, /// The host node no longer knows this device's key: the share was /// removed, or this device was forgotten. Only `leave` makes sense. Removed, /// The host node revoked this device. Only `leave` makes sense. Revoked, } impl ShareState { /// True if the state means we can't use this share anymore. I.e. the host /// node has forgotten about our guest node or revoked its access. pub fn is_terminal(self) -> bool { matches!(self, ShareState::Removed | ShareState::Revoked) } } // The wire types the surface carries, mirrored for the bindings. #[uniffi::remote(Record)] pub struct SharedApp { pub id: String, pub name: String, pub kind: AppKind, pub mode: AppProxyMode, pub start_url: Option, pub proxied_origins: Vec, } #[uniffi::remote(Enum)] pub enum AppKind { Web, Jellyfin, Immich, } #[uniffi::remote(Enum)] pub enum AppProxyMode { ReverseProxy, ForwardProxy, Unknown, } #[uniffi::remote(Enum)] pub enum Transport { WispersConnect, Iroh, Tailscale, } /// Which transport an invite code names, or why it is no invite. Offline; /// for checking a scanned code before `join`. #[uniffi::export] pub fn validate_invite(invite_code: String) -> Result { Ok(wire::Invite::parse(&invite_code) .map_err(|e| SdkError::InvalidInvite(e.to_string()))? .transport()) } /// Where a forward-proxy app's proxied origins point, for the client app to /// set up its WebView's proxy with. `None` if one of them doesn't parse: the /// host node is newer than this SDK, and the app can't be opened. #[uniffi::export] pub fn proxied_targets(app: SharedApp) -> Option> { app.proxied_origins .iter() .map(|origin| { let origin = origin.parse::().ok()?; Some(ProxiedTarget { host: origin.host().to_owned(), port: origin.port(), https: origin.is_https(), loopback: origin.is_loopback(), }) }) .collect() } /// One of a forward-proxy app's proxied origins, taken apart. #[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] pub struct ProxiedTarget { /// A lowercase DNS name, an IPv4 literal, or an IPv6 literal in brackets. pub host: String, /// The port, the scheme's default if the origin names none. pub port: u16, pub https: bool, /// Whether the host names the loopback (`localhost`, `127.0.0.1`, /// `[::1]`), which some WebViews never send to a proxy. pub loopback: bool, } /// What one of a share's certificates ([`Share::ca_certificates`]) can vouch /// for, for the text that offers to install it. `None` if it isn't an X.509 /// certificate in PEM. #[uniffi::export] pub fn describe_certificate(pem: String) -> Option { certificates::describe(&pem) } #[uniffi::export] impl Client { /// Opens the store under `data_dir` on a runtime of the client's own, /// and installs the process-wide crypto provider the transports need, /// if nobody has yet. #[uniffi::constructor] pub fn new(config: ClientConfig) -> Result> { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|e| SdkError::Internal(format!("starting the client's tokio runtime: {e}")))?; let handle = runtime.handle().clone(); Self::open(config, handle, Some(runtime)) } /// Joins the share the invite is for: makes this device a guest node of /// it and stores what the host node says the share is. A failed join /// leaves nothing behind. pub async fn join(&self, invite_code: String) -> Result { let invite = wire::Invite::parse(&invite_code) .map_err(|e| SdkError::InvalidInvite(e.to_string()))?; let client = self.clone(); self.on_runtime(async move { let row = client.inner.db.new_row().map_err(SdkError::storage)?; let result = async { let info = transports::join(invite, &row, &client.inner.secrets) .await .map_err(|e| { if e.is::() { SdkError::ClientTooOld(e.to_string()) } else if let Some(refused) = e.downcast_ref::() { SdkError::InviteRefused(refused.to_string()) } else { SdkError::host_node(e) } })?; client.record_join(&row, &info).map_err(SdkError::storage) } .await; match result { Ok(share) => { client.inner.observer.on_share_changed(share.clone()); Ok(share) } Err(e) => { let _ = row.delete_row(); Err(e) } } }) .await } /// Every joined share, from the store. Never waits for the network. pub fn shares(&self) -> Result> { self.inner .db .get_all_rows() .map_err(SdkError::storage)? .iter() .map(|row| row.read_share().map_err(SdkError::storage)) .collect() } /// One share by id or label, from the store. pub fn share(&self, key: String) -> Result> { self.inner .db .find_row(&key) .map_err(SdkError::storage)? .as_ref() .map(|row| row.read_share().map_err(SdkError::storage)) .transpose() } /// Asks the host node whether the share has changed since we stored a copy /// of its config. `None` means nothing changed. `Some` can mean several /// things: the share config has changed, or the host node has changed its /// mind about serving our guest node (it got removed, revoked, or is simply /// too old). pub async fn refresh(&self, share: ShareId) -> Result> { let client = self.clone(); self.on_runtime(async move { let row = client.row(&share)?; let before = row.read_share().map_err(SdkError::storage)?; if before.state.is_terminal() { return Ok(None); } let node = match client.restore_guest_node(share.as_str()).await? { Lookup::Live(node) => node, // The store learned this during restore, above. Lookup::Dead(_) => return Ok(Some(row.read_share().map_err(SdkError::storage)?)), Lookup::Unknown => return Err(SdkError::NoSuchShare(share.to_string())), }; // Refresh the guest node's metadata. Fail on transient errors // (unreachable etc). Other errors get written to the DB. if let Err(guest_node::RefreshError::Transient(e)) = node.refresh().await { return Err(SdkError::host_node(e)); } // Re-read the row, which was updated by refresh(). let after = row.read_share().map_err(SdkError::storage)?; Ok((after != before).then_some(after)) }) .await } /// Leaves the share: tells the host node, or the hub, where possible, and /// forgets the share and its secrets on this device either way. pub async fn leave(&self, share: ShareId) -> Result<()> { let client = self.clone(); self.on_runtime(async move { let row = client.row(&share)?; // Let the live guest node go first: the transport that says // goodbye must not compete with it for the same key. let live = client .inner .guest_nodes .lock() .expect("unpoisoned") .remove(&share); if let Some(node) = live.as_ref().and_then(|cell| cell.get()) { node.close().await; } transports::leave(&row, &client.inner.secrets) .await .map_err(SdkError::host_node)?; row.delete_row().map_err(SdkError::storage) }) .await } /// Check connection health and drop/redial the connection if necessary. /// This is primarily useful on mobile, when the app was in the background /// and just returned to the foreground. Dead connections get redialed if /// they had been used recently. Otherwise, we just drop them. /// /// The check costs one round trip on a live connection and up to 2 seconds /// on a dead one. pub async fn check_connections(&self) { // Each check runs on the client's runtime; an outcome the guest node // did not already log is a cancelled task, nothing to report. let checks: Vec<_> = self .live_guest_nodes() .into_iter() .map(|node| { self.inner .runtime .spawn(async move { node.check_connection().await }) }) .collect(); for check in checks { let _ = check.await; } } /// Close every cached connection. Use if the network changed and we know /// that existing connections must be dead. You may want to follow up with /// `check_connections` to redial the connections that were recently in use. pub fn close_connections(&self) { for node in self.live_guest_nodes() { node.drop_connection(); } } /// Starts a loopback proxy on one port, serving all shares this client has /// joined. Requests get routed by their `Host` header, using the scheme /// `http://..wa.localhost:`. pub async fn start_host_routed_proxy( &self, port: u16, auth_mode: ProxyAuthMode, ) -> Result> { let authenticator = match auth_mode { ProxyAuthMode::AppCookie { cookie } => reverse_proxy::Authenticator::for_cookie(cookie), ProxyAuthMode::Pairing { token_lifetime } => reverse_proxy::Authenticator::for_pairing( pairing::CookieIssuer::restore_or_mint(&*self.inner.secrets, token_lifetime)?, ), }; let client = self.clone(); self.on_runtime(async move { let (port, listeners) = loopback::bind_port(port) .await .map_err(SdkError::host_node)?; let serve = { let client = client.clone(); let authenticator = authenticator.clone(); move |listener| { client.inner.runtime.spawn(reverse_proxy::accept_loop( listener, client.clone(), reverse_proxy::RouteSource::HostHeader, Some(authenticator.clone()), )) } }; Ok(Arc::new(HostRoutedProxy { client, authenticator, bound_port: BoundPort::serve(port, listeners, serve), })) }) .await } /// A loopback proxy with one `127.0.0.1` port per app, each bound when /// the app's URL is first asked for. For iOS, where `*.localhost` does /// not resolve. Serves until dropped. With a `required_cookie`, requests /// without it get a 403. pub fn start_per_app_proxy(&self, required_cookie: Option) -> Arc { Arc::new(PerAppProxy { client: self.clone(), required_cookie, bound_ports: Mutex::new(HashMap::new()), }) } /// Starts the loopback forward proxy for desktop clients, with a port per /// share that only processes of this OS user may connect to (see /// `forward_proxy::ShareSource::Fixed`). macOS, Linux and Windows only. pub fn start_per_share_forward_proxy(&self) -> Arc { Arc::new(PerShareForwardProxy { client: self.clone(), bound_ports: Mutex::new(HashMap::new()), }) } /// Starts the loopback forward proxy for the shares' forward-proxy apps, /// on one port for all shares; `0` picks a free one. A share's webview /// uses it as its HTTP proxy for the share's proxied origins and answers /// its 407 with the share's [`CredentialRoutedForwardProxy::credential`]. For mobile client /// apps, which can give each share's webview a credential of its own. pub async fn start_credential_routed_forward_proxy( &self, port: u16, ) -> Result> { let credential_issuer = Arc::new(forward_proxy::CredentialIssuer::default()); let client = self.clone(); self.on_runtime(async move { let (port, listeners) = loopback::bind_port(port) .await .map_err(SdkError::host_node)?; let serve = { let client = client.clone(); let credential_issuer = credential_issuer.clone(); move |listener| { client.inner.runtime.spawn(forward_proxy::accept_loop( listener, client.clone(), forward_proxy::ShareSource::Credential(credential_issuer.clone()), )) } }; Ok(Arc::new(CredentialRoutedForwardProxy { client, credential_issuer, bound_port: Arc::new(BoundPort::serve(port, listeners, serve)), loopback_forwarders: Default::default(), })) }) .await } } impl Client { /// Opens the store under `data_dir` on the caller's runtime. For Rust /// apps that run tokio already. pub fn new_with_runtime( config: ClientConfig, runtime: tokio::runtime::Handle, ) -> Result { Ok(Arc::try_unwrap(Self::open(config, runtime, None)?) .unwrap_or_else(|client| (*client).clone())) } fn open( config: ClientConfig, runtime: tokio::runtime::Handle, owned_runtime: Option, ) -> Result> { if rustls::crypto::CryptoProvider::get_default().is_none() { // A second installer racing us loses harmlessly: either provider // will do. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); } let data_dir = PathBuf::from(&config.data_dir); let db = storage::DB::open(&data_dir).map_err(SdkError::storage)?; let secrets = config .secrets .unwrap_or_else(|| Arc::new(FileSecretStore::new(data_dir.join("secrets")))); let observer = config.observer.unwrap_or_else(|| Arc::new(NoObserver)); Ok(Arc::new(Client { inner: Arc::new(ClientInner { db, secrets, observer, runtime, owned_runtime: Mutex::new(owned_runtime), guest_nodes: Mutex::new(HashMap::new()), }), })) } /// Whether this device trusts each `https` origin of the share's /// forward-proxy apps, as the platform's verifier decides, by a TLS /// handshake through a tunnel to each. For the desktop app, whose /// browsers never tell it about a certificate error; the mobile apps /// learn it from their webviews. Origins that don't parse are left out. #[cfg(not(any(target_os = "android", target_os = "ios")))] pub async fn check_origin_trust(&self, share: ShareId) -> Result> { let mut origins: Vec = Vec::new(); let apps = self.row(&share)?.read_apps().map_err(SdkError::storage)?; let https_origins = apps .iter() .filter(|app| app.mode == AppProxyMode::ForwardProxy) .flat_map(|app| &app.proxied_origins) .filter_map(|origin| origin.parse::().ok()) .filter(|origin| origin.is_https()); for origin in https_origins { if !origins.contains(&origin) { origins.push(origin); } } if origins.is_empty() { return Ok(Vec::new()); } let node = match self.restore_guest_node(share.as_str()).await? { Lookup::Live(node) => node, Lookup::Dead(state) => { return Err(SdkError::HostNode(format!( "the share is {}", state.as_str() ))); } Lookup::Unknown => return Err(SdkError::NoSuchShare(share.to_string())), }; certificates::check_origins(&node, origins).await } /// The local bookkeeping of any join, once the host node has answered: /// the names, the app list, and marking the row complete so it survives /// the next start. fn record_join(&self, row: &storage::Row, info: &wire::ShareInfo) -> anyhow::Result { let share_id = row.share_id()?; row.write_share_info(info)?; let name = if info.name.is_empty() { share_id.to_string() } else { info.name.clone() }; row.write_display_name(&name)?; let label = host_slug(&name).unwrap_or_else(|| share_id.to_string()); row.write_deduped_hostname(&label)?; row.mark_complete()?; row.read_share() } /// The guest nodes restored so far. fn live_guest_nodes(&self) -> Vec> { self.inner .guest_nodes .lock() .expect("unpoisoned") .values() .filter_map(|cell| cell.get().cloned()) .collect() } fn row(&self, share: &ShareId) -> Result { self.inner .db .find_row(share.as_str()) .map_err(SdkError::storage)? .ok_or_else(|| SdkError::NoSuchShare(share.to_string())) } /// Restore the guest node of a share, by id or label. The first call /// restores its transport, which may reach the hub. Later calls find it /// cached. A terminal answer from the transport is recorded in the store, /// so the share is dead from then on without dialling again. pub(crate) async fn restore_guest_node(&self, key: &str) -> Result { let Some(row) = self.inner.db.find_row(key).map_err(SdkError::storage)? else { return Ok(Lookup::Unknown); }; if let Some(state) = row .read_terminal_state() .map_err(SdkError::storage)? .as_deref() .and_then(TerminalState::parse) { return Ok(Lookup::Dead(state)); } let share_id = row.share_id().map_err(SdkError::storage)?; let cell = self .inner .guest_nodes .lock() .expect("unpoisoned") .entry(share_id.clone()) .or_default() .clone(); let restored = cell .get_or_try_init(|| async { let label = row .read_names() .map_err(transports::TransportError::Transient)? .2; let transport = transports::restore(row.clone(), self.inner.secrets.clone()).await?; Ok::<_, transports::TransportError>(Arc::new(GuestNode::new( label, row.clone(), transport, self.inner.observer.clone(), ))) }) .await; match restored { Ok(node) => Ok(Lookup::Live(node.clone())), Err(transports::TransportError::Terminal(state)) => { row.write_terminal_state(state.as_str()) .map_err(SdkError::storage)?; self.inner .observer .on_share_changed(row.read_share().map_err(SdkError::storage)?); Ok(Lookup::Dead(state)) } Err(transports::TransportError::Transient(e)) => Err(SdkError::host_node(e)), } } /// Runs `work` on the client's runtime, whatever context the caller /// awaits from. async fn on_runtime(&self, work: F) -> Result where T: Send + 'static, F: Future> + Send + 'static, { self.inner .runtime .spawn(work) .await .map_err(|e| SdkError::Internal(format!("the client's task was cancelled: {e}")))? } } /// What the proxy finds for a share label. pub(crate) enum Lookup { Live(Arc), Dead(TerminalState), Unknown, } pub(crate) struct NoObserver; impl Observer for NoObserver { fn on_share_changed(&self, _: Share) {} } /// Free-form name -> DNS-label-safe slug, or None if nothing usable remains. fn host_slug(name: &str) -> Option { // Remove apostrophes, so "Bob's app" becomes "bobs-app", not "bob-s-app". let cleaned = name.replace(['\'', '’'], ""); // Slugify, but keep it to 63 chars, to produce a legal DNS label. let mut s = slug::slugify(cleaned); s.truncate(63); // Truncation can leave a trailing '-'. let s = s.trim_end_matches('-'); (!s.is_empty()).then(|| s.to_string()) } /// How a browser authenticates to a proxy port. The port is reachable by every /// process on the device, so the proxy demands a cookie on every request. The /// mode says who mints it and how it reaches the browser. #[derive(Clone, Debug, uniffi::Enum)] pub enum ProxyAuthMode { /// The app mints a cookie and injects it directly into the webview that /// talks to the proxy. The proxy only checks that the cookie is present. AppCookie { cookie: RequiredCookie }, /// The proxy pairs with the user's browser to write the cookie. The app /// generates a registration token and a URL like /// http://wa.localhost:/pair?token=T&share=S&app=A. When the user /// follows the URL, the proxy checks the token, then redirects to the app. Pairing { token_lifetime: Duration }, } /// A running loopback proxy on one port; the app and share are in the /// `Host` header. Dropping it stops accepting connections. #[derive(uniffi::Object)] pub struct HostRoutedProxy { client: Client, authenticator: reverse_proxy::Authenticator, bound_port: BoundPort, } #[uniffi::export] impl HostRoutedProxy { pub fn port(&self) -> u16 { self.bound_port.port } /// Where to send the browser for a given shared app. Uses the pairing /// endpoint with [`ProxyAuthMode::Pairing`], the app's base URL with /// [`ProxyAuthMode::AppCookie`]. pub fn browse_url(&self, share: ShareId, app_id: String) -> Result { let label = self.share_label(&share)?; Ok(match self.authenticator.issuer() { Some(issuer) => { reverse_proxy::pair_url(self.port(), &issuer.mint_token(), &label, &app_id) } None => self.app_origin(&label, &app_id), }) } /// The URL where a given shared app is served, without any pairing logic. /// With [`ProxyAuthMode::AppCookie`], this is the domain for the injected /// cookie. pub fn base_url(&self, share: ShareId, app_id: String) -> Result { let label = self.share_label(&share)?; Ok(self.app_origin(&label, &app_id)) } } impl HostRoutedProxy { fn app_origin(&self, label: &str, app_id: &str) -> String { format!( "http://{app_id}.{label}.{}:{}", reverse_proxy::PROXY_DOMAIN, self.port() ) } fn share_label(&self, share: &ShareId) -> Result { Ok(self .client .row(share)? .read_names() .map_err(SdkError::storage)? .2) } } impl Drop for HostRoutedProxy { fn drop(&mut self) { self.bound_port.release(); } } /// A running loopback proxy with a port per app, bound on first use and /// remembered across restarts so a webview's origin stays put. Dropping it /// stops accepting connections. #[derive(uniffi::Object)] pub struct PerAppProxy { client: Client, required_cookie: Option, /// Each app's port, bound by whoever asks for it first. bound_ports: Mutex>>>, } /// An app, as the proxy tells them apart: its share's id and its own. type AppKey = (ShareId, String); #[uniffi::export] impl PerAppProxy { /// What to open for an app of a share: `http://127.0.0.1:`, a /// literal address so no name resolution is involved. Binds the port /// first if this is the app's first use. pub async fn base_url(&self, share: ShareId, app_id: String) -> Result { let port = self.app_port(share, app_id).await?; Ok(format!("http://127.0.0.1:{port}")) } /// Binds every app's port again, on the same port numbers. iOS reclaims /// the listening sockets of a suspended app, so call this whenever the /// app comes back to the foreground. pub async fn rebind(&self) { let cells: Vec<_> = self .bound_ports .lock() .expect("unpoisoned") .values() .cloned() .collect(); let _ = self .client .on_runtime(async move { for cell in cells { if let Some(bound) = cell.get() { bound.rebind().await; } } Ok(()) }) .await; } } impl PerAppProxy { /// The app's port, binding it on the app's first use: the remembered /// port if it is still free, else a new one, which is then remembered. async fn app_port(&self, share: ShareId, app_id: String) -> Result { let cell = self .bound_ports .lock() .expect("unpoisoned") .entry((share.clone(), app_id.clone())) .or_default() .clone(); let bound = cell .get_or_try_init(|| async { let row = self.client.row(&share)?; let app = app_id.clone(); let (port, listeners) = self .client .on_runtime(async move { if let Some(port) = row.read_app_port(&app).map_err(SdkError::storage)? && let Ok(bound) = loopback::bind_port(port).await { return Ok(bound); } let (port, listeners) = loopback::bind_port(0).await.map_err(SdkError::host_node)?; row.write_app_port(&app, port).map_err(SdkError::storage)?; Ok((port, listeners)) }) .await?; let route_source = reverse_proxy::RouteSource::Fixed { share, app: app_id }; let authenticator = self .required_cookie .clone() .map(reverse_proxy::Authenticator::for_cookie); let client = self.client.clone(); let serve = move |listener| { client.inner.runtime.spawn(reverse_proxy::accept_loop( listener, client.clone(), route_source.clone(), authenticator.clone(), )) }; Ok::<_, SdkError>(BoundPort::serve(port, listeners, serve)) }) .await?; Ok(bound.port) } } impl Drop for PerAppProxy { fn drop(&mut self) { for cell in self.bound_ports.lock().expect("unpoisoned").values() { if let Some(bound) = cell.get() { bound.release(); } } } } /// A running loopback forward proxy, serving every share's forward-proxy /// apps on one port. Dropping it stops accepting connections, and the /// shares' credentials end with it. #[derive(uniffi::Object)] pub struct CredentialRoutedForwardProxy { client: Client, credential_issuer: Arc, bound_port: Arc, /// Each share's loopback forwarder while it lives. loopback_forwarders: Arc>>>, } #[uniffi::export] impl CredentialRoutedForwardProxy { pub fn port(&self) -> u16 { self.bound_port.port } /// The realm of the proxy's 407s. The client app answers a challenge with /// the share's credential only if it names this realm and the proxy's /// host. (Android's callback gives no port, so the realm is what tells the /// proxy apart from a shared app on `localhost`.) pub fn realm(&self) -> String { forward_proxy::REALM.to_owned() } /// What a share's webview answers the proxy's 407s with. Minted lazily, /// valid as long as this proxy runs. pub fn credential(&self, share: ShareId) -> Result { self.client.row(&share)?; Ok(ProxyCredential { username: share.to_string(), password: self.credential_issuer.mint_password(&share), }) } /// Binds the proxy's port, and those of every live loopback forwarder, /// again, on the same port numbers. iOS reclaims the listening sockets of a /// suspended app, so call this whenever the app comes back to the /// foreground. pub async fn rebind(&self) { let own = self.bound_port.clone(); let forwarders = self.loopback_forwarders.clone(); let _ = self .client .on_runtime(async move { own.rebind().await; let live: Vec<_> = forwarders .lock() .await .values() .filter_map(Weak::upgrade) .collect(); for forwarder in live { for bound in &forwarder.bound_ports { bound.rebind().await; } } Ok(()) }) .await; } /// For WebViews that don't send loopback hosts to a proxy (iOS), which /// breaks normal forward-proxy mode whenver a shared app uses /// localhost:port-style addresses. Instead, this attempts to bind the same /// ports as the shared app, then proxies requests reaching those ports. /// This can clash, in which case we fail with [`SdkError::PortInUse`]. pub async fn forward_loopback_origins(&self, share: ShareId) -> Result> { let client = self.client.clone(); let forwarders = self.loopback_forwarders.clone(); self.client .on_runtime(async move { let mut forwarders = forwarders.lock().await; if let Some(live) = forwarders.get(&share).and_then(Weak::upgrade) { return Ok(live); } forwarders.retain(|_, forwarder| forwarder.strong_count() > 0); let apps = client.row(&share)?.read_apps().map_err(SdkError::storage)?; // Released by its drop if a port fails to bind. let mut forwarder = LoopbackForwarder { cookie: RequiredCookie { name: loopback_forwarding::COOKIE_NAME.to_owned(), value: secrets::mint_secret(), }, bound_ports: Vec::new(), }; for (port, hosts) in loopback_forwarding::loopback_ports(&apps) { if let Some(holder) = forwarders.iter().find_map(|(holder, other)| { other .upgrade() .is_some_and(|other| other.ports().contains(&port)) .then_some(holder) }) { let name = client .row(holder)? .read_names() .map_err(SdkError::storage)? .1; return Err(SdkError::PortInUse(format!( "Port {port} is in use by an app of the share {name}." ))); } let (_, listeners) = loopback::bind_port(port).await.map_err(|e| { tracing::warn!( port, error = format!("{e:#}"), "could not bind a loopback port" ); SdkError::PortInUse(format!( "Port {port} is already in use on this device." )) })?; let forwarded = Arc::new(loopback_forwarding::ForwardedPort { client: client.clone(), share: share.clone(), port, hosts, cookie: forwarder.cookie.clone(), share_origins: loopback_forwarding::share_origins(&apps), }); let runtime = client.inner.runtime.clone(); let serve = move |listener| { runtime.spawn(loopback_forwarding::accept_loop( listener, forwarded.clone(), )) }; forwarder .bound_ports .push(BoundPort::serve(port, listeners, serve)); } let forwarder = Arc::new(forwarder); forwarders.insert(share, Arc::downgrade(&forwarder)); Ok(forwarder) }) .await } } impl Drop for CredentialRoutedForwardProxy { fn drop(&mut self) { self.bound_port.release(); } } /// A running loopback forward proxy with a port per share, for desktop /// clients (see [`Client::start_per_share_forward_proxy`]). Dropping it stops /// accepting connections. #[derive(uniffi::Object)] pub struct PerShareForwardProxy { client: Client, /// Each share's port, bound by whoever asks for it first. bound_ports: Mutex>>>, } #[uniffi::export] impl PerShareForwardProxy { /// The share's port on `127.0.0.1`, for its browser profile's proxy /// setting. Binds it when on first use, using the remembered port from the /// last run if available (will try for a few seconds, as an earlier run of /// the app may still be exiting), otherwise a new port. If the share's /// browser got started using a a different port, it must be restarted. pub async fn share_port(&self, share: ShareId) -> Result { let cell = self .bound_ports .lock() .expect("unpoisoned") .entry(share.clone()) .or_default() .clone(); let bound = cell .get_or_try_init(|| async { let row = self.client.row(&share)?; let (port, listeners) = self .client .on_runtime(async move { if let Some(port) = row.read_forward_proxy_port().map_err(SdkError::storage)? && let Ok(bound) = loopback::rebind_port(port).await { return Ok(bound); } let (port, listeners) = loopback::bind_port(0).await.map_err(SdkError::host_node)?; row.write_forward_proxy_port(port) .map_err(SdkError::storage)?; Ok((port, listeners)) }) .await?; let client = self.client.clone(); let serve = move |listener| { client.inner.runtime.spawn(forward_proxy::accept_loop( listener, client.clone(), forward_proxy::ShareSource::Fixed(share.clone()), )) }; Ok::<_, SdkError>(BoundPort::serve(port, listeners, serve)) }) .await?; Ok(bound.port) } /// Closes the share's port. Used when we leave a share. Already accepted /// connections run until the browser closes them. pub fn release(&self, share: ShareId) { let cell = self.bound_ports.lock().expect("unpoisoned").remove(&share); if let Some(bound) = cell.as_ref().and_then(|cell| cell.get()) { bound.release(); } } } impl Drop for PerShareForwardProxy { fn drop(&mut self) { for cell in self.bound_ports.lock().expect("unpoisoned").values() { if let Some(bound) = cell.get() { bound.release(); } } } } /// A share's Basic credential for the [`CredentialRoutedForwardProxy`]. #[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] pub struct ProxyCredential { pub username: String, pub password: String, } /// A share's `http` loopback origins, forwarded to its host node while this /// lives (see [`CredentialRoutedForwardProxy::forward_loopback_origins`]). Dropping the last /// reference releases the ports. #[derive(uniffi::Object)] pub struct LoopbackForwarder { cookie: RequiredCookie, bound_ports: Vec, } #[uniffi::export] impl LoopbackForwarder { /// The cookie every forwarded request must carry. The client app sets it /// in the share's data store for `localhost` and `127.0.0.1`, `HttpOnly` /// so pages can't read or delete it. pub fn cookie(&self) -> RequiredCookie { self.cookie.clone() } /// The ports being forwarded. pub fn ports(&self) -> Vec { self.bound_ports.iter().map(|bound| bound.port).collect() } } impl Drop for LoopbackForwarder { fn drop(&mut self) { for bound in &self.bound_ports { bound.release(); } } } /// A port bound on each loopback address, with an accept loop per address. /// It remembers how to serve the port, so that it can bind it again. struct BoundPort { port: u16, /// Starts the accept loop for one of the port's listeners. serve: Box tokio::task::JoinHandle<()> + Send + Sync>, accept_loops: Mutex>>, } impl BoundPort { /// Serves listeners just bound to `port`, one accept loop each. fn serve( port: u16, listeners: Vec, serve: impl Fn(tokio::net::TcpListener) -> tokio::task::JoinHandle<()> + Send + Sync + 'static, ) -> Self { let accept_loops = listeners.into_iter().map(&serve).collect(); BoundPort { port, serve: Box::new(serve), accept_loops: Mutex::new(accept_loops), } } /// Binds the port again and serves the new listeners. If it can't be bound /// again, it stays unserved, with a warning. async fn rebind(&self) { let old = std::mem::take(&mut *self.accept_loops.lock().expect("unpoisoned")); for accept_loop in &old { accept_loop.abort(); } for accept_loop in old { let _ = accept_loop.await; } match loopback::bind_port(self.port).await { Ok((_, listeners)) => { let new = listeners.into_iter().map(&self.serve).collect(); *self.accept_loops.lock().expect("unpoisoned") = new; } Err(e) => tracing::warn!( port = self.port, error = format!("{e:#}"), "could not bind a loopback port again" ), } } /// Stops the accept loops, which closes the port. fn release(&self) { for accept_loop in self.accept_loops.lock().expect("unpoisoned").iter() { accept_loop.abort(); } } } impl Drop for ClientInner { fn drop(&mut self) { // A runtime must not be dropped from inside another runtime's task, // which is where a client may well go out of scope. if let Some(runtime) = self.owned_runtime.lock().expect("unpoisoned").take() { runtime.shutdown_background(); } } } #[cfg(test)] mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; fn scratch_dir() -> PathBuf { std::env::temp_dir().join(format!("wispers-access-sdk-{}", uuid::Uuid::new_v4())) } fn client_in(data_dir: &std::path::Path) -> Arc { Client::new(ClientConfig { data_dir: data_dir.to_string_lossy().into_owned(), secrets: None, observer: None, }) .unwrap() } /// Deletes a client's data dir once it can. Windows doesn't delete open /// files, and a dropped client's database closes only after its runtime /// has dropped the tasks it aborted. fn remove_scratch_dir(dir: &std::path::Path) { let deadline = std::time::Instant::now() + Duration::from_secs(5); while let Err(e) = std::fs::remove_dir_all(dir) { assert!( std::time::Instant::now() < deadline, "could not remove {}: {e}", dir.display() ); std::thread::sleep(Duration::from_millis(50)); } } #[test] fn a_client_starts_offline_with_its_own_runtime() { let data_dir = scratch_dir(); let client = client_in(&data_dir); assert!(client.shares().unwrap().is_empty()); assert!(client.share("nope".into()).unwrap().is_none()); assert!(matches!( validate_invite("nope".into()), Err(SdkError::InvalidInvite(_)) )); drop(client); remove_scratch_dir(&data_dir); } #[tokio::test] async fn a_client_can_be_dropped_inside_another_runtime() { let data_dir = scratch_dir(); let client = client_in(&data_dir); let proxy = client .start_host_routed_proxy( 0, ProxyAuthMode::Pairing { token_lifetime: Duration::from_secs(60), }, ) .await .unwrap(); let port = proxy.port(); assert_ne!(port, 0); // Both loopback families answer; IPv6 only where the machine has it. tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, port)) .await .unwrap(); if tokio::net::TcpStream::connect((Ipv6Addr::LOCALHOST, port)) .await .is_err() { eprintln!("no IPv6 loopback on this machine"); } drop(proxy); drop(client); remove_scratch_dir(&data_dir); } #[tokio::test] async fn per_app_ports_survive_a_new_proxy() { let data_dir = scratch_dir(); let client = client_in(&data_dir); let share = stored_share(&client); let proxy = client.start_per_app_proxy(None); let first = proxy.base_url(share.clone(), "echo".into()).await.unwrap(); assert!(first.starts_with("http://127.0.0.1:")); assert_eq!( proxy.base_url(share.clone(), "echo".into()).await.unwrap(), first ); assert_ne!( proxy.base_url(share.clone(), "other".into()).await.unwrap(), first ); drop(proxy); let proxy = client.start_per_app_proxy(None); assert_eq!(proxy.base_url(share, "echo".into()).await.unwrap(), first); drop(proxy); drop(client); remove_scratch_dir(&data_dir); } #[tokio::test] async fn per_app_ports_are_served_again_after_a_rebind() { let data_dir = scratch_dir(); let client = client_in(&data_dir); let share = stored_share(&client); let cookie = RequiredCookie { name: "__wispers_proxy_auth".into(), value: "s3cret".into(), }; let proxy = client.start_per_app_proxy(Some(cookie)); let base = proxy.base_url(share, "echo".into()).await.unwrap(); let port: u16 = base.rsplit(':').next().unwrap().parse().unwrap(); proxy.rebind().await; // Any answer will do: the port is listening. let req = hyper::Request::get("/").body(String::new()).unwrap(); assert_eq!(send(port, req).await.status(), hyper::StatusCode::FORBIDDEN); drop(proxy); drop(client); remove_scratch_dir(&data_dir); } #[tokio::test] async fn a_required_cookie_is_demanded_on_every_request() { let data_dir = scratch_dir(); let client = client_in(&data_dir); let cookie = RequiredCookie { name: "__wispers_proxy_auth".into(), value: "s3cret".into(), }; let proxy = client .start_host_routed_proxy( 0, ProxyAuthMode::AppCookie { cookie: cookie.clone(), }, ) .await .unwrap(); let without = hyper::Request::get("/") .header("host", "echo.nope.wa.localhost") .body(String::new()) .unwrap(); assert_eq!( send(proxy.port(), without).await.status(), hyper::StatusCode::FORBIDDEN ); let with = hyper::Request::get("/") .header("host", "echo.nope.wa.localhost") .header("cookie", format!("{}={}", cookie.name, cookie.value)) .body(String::new()) .unwrap(); // Past the check: the share is what's unknown now. assert_eq!( send(proxy.port(), with).await.status(), hyper::StatusCode::NOT_FOUND ); // There is no pairing to be had under an app's own cookie: the // browser goes straight to the app. let share = stored_share(&client); assert_eq!( proxy.browse_url(share.clone(), "echo".into()).unwrap(), proxy.base_url(share, "echo".into()).unwrap() ); let pair = hyper::Request::get("/pair?token=x&share=nope&app=echo") .header("host", "wa.localhost") .body(String::new()) .unwrap(); assert_eq!( send(proxy.port(), pair).await.status(), hyper::StatusCode::NOT_FOUND ); drop(proxy); drop(client); remove_scratch_dir(&data_dir); } #[tokio::test] async fn a_browse_url_pairs_the_browser_once() { let data_dir = scratch_dir(); let client = client_in(&data_dir); let share = stored_share(&client); let auth_mode = ProxyAuthMode::Pairing { token_lifetime: Duration::from_secs(60), }; let proxy = client .start_host_routed_proxy(0, auth_mode.clone()) .await .unwrap(); let port = proxy.port(); let host = format!("wa.localhost:{port}"); let browse_url = proxy.browse_url(share.clone(), "echo".into()).unwrap(); let path_and_query = browse_url.strip_prefix(&format!("http://{host}")).unwrap(); let pair = |cookie: Option<&str>| { let mut req = hyper::Request::get(path_and_query).header("host", &host); if let Some(cookie) = cookie { req = req.header("cookie", cookie); } req.body(String::new()).unwrap() }; // First visit: the cookie arrives with the redirect to the app. let paired = send(port, pair(None)).await; assert_eq!(paired.status(), hyper::StatusCode::FOUND); assert_eq!( paired.headers()["location"], format!("http://echo.rt.wa.localhost:{port}/") ); let set_cookie = paired.headers()["set-cookie"].to_str().unwrap().to_owned(); assert!( set_cookie.contains("; Domain=wa.localhost; "), "{set_cookie}" ); assert!( set_cookie.contains("; HttpOnly; SameSite=Lax"), "{set_cookie}" ); let cookie = set_cookie.split(';').next().unwrap().to_owned(); // The token is spent, the cookie is what counts from now on. A // browser that has neither gets an error page. let spent = send(port, pair(None)).await; assert_eq!(spent.status(), hyper::StatusCode::FORBIDDEN); assert_eq!(spent.headers()["content-type"], "text/html; charset=utf-8"); let again = send(port, pair(Some(&cookie))).await; assert_eq!(again.status(), hyper::StatusCode::FOUND); assert!(!again.headers().contains_key("set-cookie")); // A paired browser gets past the check on the app hosts; the app // itself never sees the cookie, so an unknown share is all it finds. let app = hyper::Request::get("/") .header("host", format!("echo.nope.wa.localhost:{port}")) .header("cookie", &cookie) .body(String::new()) .unwrap(); assert_eq!(send(port, app).await.status(), hyper::StatusCode::NOT_FOUND); // The pairing outlives the proxy: the next one honours the same cookie. drop(proxy); let proxy = client.start_host_routed_proxy(0, auth_mode).await.unwrap(); let port = proxy.port(); let app = hyper::Request::get("/") .header("host", format!("echo.nope.wa.localhost:{port}")) .header("cookie", &cookie) .body(String::new()) .unwrap(); assert_eq!(send(port, app).await.status(), hyper::StatusCode::NOT_FOUND); drop(proxy); drop(client); remove_scratch_dir(&data_dir); } /// One HTTP/1.1 request to a proxy port, on a connection of its own. The /// proxy closes the connection after the response. Our end is a task on /// the test's runtime, which `remove_scratch_dir` blocks, and the proxy's /// end holds the client, and with it the database, which Windows can't /// delete while it is open. async fn send( port: u16, mut req: hyper::Request, ) -> hyper::Response { req.headers_mut().insert( hyper::header::CONNECTION, hyper::header::HeaderValue::from_static("close"), ); let stream = tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, port)) .await .unwrap(); let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)) .await .unwrap(); tokio::spawn(conn); sender.send_request(req).await.unwrap() } /// Adds a share the store knows, without a host node behind it, as "rt". /// The row is dropped on return. It keeps the database open, and Windows /// can't delete the data dir while it is. fn stored_share(client: &Client) -> ShareId { let row = client.inner.db.new_row().unwrap(); row.write_deduped_hostname("rt").unwrap(); row.mark_complete().unwrap(); row.share_id().unwrap() } #[test] fn proxied_targets_take_origins_apart() { let app = |origins: &[&str]| SharedApp { id: "a".into(), name: "a".into(), kind: AppKind::Web, mode: AppProxyMode::ForwardProxy, start_url: None, proxied_origins: origins.iter().map(|o| (*o).to_owned()).collect(), }; let target = |host: &str, port, https, loopback| ProxiedTarget { host: host.into(), port, https, loopback, }; assert_eq!( proxied_targets(app(&["http://localhost:4200", "https://sso.internal"])), Some(vec![ target("localhost", 4200, false, true), target("sso.internal", 443, true, false), ]) ); assert_eq!(proxied_targets(app(&["http://a:1", "nonsense"])), None); } #[test] fn labels_are_dns_safe() { assert_eq!(host_slug("Bob's Apps").as_deref(), Some("bobs-apps")); assert_eq!(host_slug("!!!"), None); assert_eq!(host_slug(&"a".repeat(70)).unwrap().len(), 63); } }