//! Shared logic for proxy implementations for serving on the loopback device. use crate::secrets::constant_time_eq; use crate::transports::TerminalState; use anyhow::{Context, Result}; use bytes::Bytes; use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::StatusCode; use hyper_util::rt::TokioIo; use std::convert::Infallible; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use std::time::Duration; use tokio::net::{TcpListener, TcpSocket}; use tokio::time::Instant; use tracing::warn; /// Like [`bind_port`], but waits up to [`REBIND_PATIENCE`] while the port is /// taken, for a port this app held before it restarted. pub async fn rebind_port(port: u16) -> Result<(u16, Vec)> { let deadline = Instant::now() + REBIND_PATIENCE; loop { match bind_port(port).await { Err(e) if port_taken(&e) && Instant::now() < deadline => { tokio::time::sleep(REBIND_INTERVAL).await; } bound => return bound, } } } /// How long [`rebind_port`] waits for a taken port, and how often it tries. const REBIND_PATIENCE: Duration = Duration::from_secs(5); const REBIND_INTERVAL: Duration = Duration::from_millis(250); fn port_taken(e: &anyhow::Error) -> bool { e.chain() .filter_map(|cause| cause.downcast_ref::()) .any(|e| e.kind() == std::io::ErrorKind::AddrInUse) } /// Bind a port on both loopback addresses, `127.0.0.1` and `::1`, since a /// browser may resolve `localhost` to either. Port `0` picks a free one. /// Returns the port and its listeners. /// /// Only a machine without an IPv6 loopback gets IPv4 alone. Otherwise, not /// being able to bind the same port on both addresses is an error. pub async fn bind_port(port: u16) -> Result<(u16, Vec)> { for _ in 0..PORT_TRIES { let ipv4_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), port); let v4_listener = listen_on(ipv4_addr).with_context(|| format!("failed to bind to {ipv4_addr}"))?; let bound_port = v4_listener.local_addr()?.port(); let ipv6_addr = SocketAddr::new(Ipv6Addr::LOCALHOST.into(), bound_port); match listen_on(ipv6_addr) { Ok(v6_listener) => return Ok((bound_port, vec![v4_listener, v6_listener])), Err(e) if no_ipv6_loopback(&e) => { warn!(error = format!("{e:#}"), "no IPv6 loopback"); return Ok((bound_port, vec![v4_listener])); } Err(_) if port == 0 => { // The auto-assigned port for IPv4 isn't available on IPv6. Retry. continue; } Err(e) => return Err(e.into()), } } anyhow::bail!("no port free on both loopback addresses after {PORT_TRIES} tries") } /// How often port `0` is retried when the IPv4 pick is taken on `::1`. const PORT_TRIES: usize = 8; /// Binds and listens like `TcpListener::bind`, but through `TcpSocket`, since /// its sockets aren't inheritable on Windows. `TcpListener::bind`'s are, so a /// browser the app starts would keep the port bound after the app quits, and /// the app couldn't bind it again. fn listen_on(addr: SocketAddr) -> std::io::Result { let socket = match addr { SocketAddr::V4(_) => TcpSocket::new_v4()?, SocketAddr::V6(_) => TcpSocket::new_v6()?, }; // Sets `SO_REUSEADDR` like `TcpListener::bind`, to rebind while an earlier // run's connections linger. Not on Windows, where it would let another // process take over the port. #[cfg(not(windows))] socket.set_reuseaddr(true)?; socket.bind(addr)?; socket.listen(LISTEN_BACKLOG) } /// The backlog `TcpListener::bind` uses. const LISTEN_BACKLOG: u32 = 128; /// Whether binding `::1` failed because the machine has no IPv6 loopback, /// rather than because the port is taken. fn no_ipv6_loopback(e: &std::io::Error) -> bool { matches!( e.kind(), std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported ) } /// A cookie a loopback proxy demands on every request, so that only the client /// app's own WebViews get through: every process on the device can connect to /// the port. #[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] pub struct RequiredCookie { pub name: String, pub value: String, } impl RequiredCookie { /// Whether the request's `Cookie` headers carry the cookie, compared in /// time that does not depend on where the values differ. pub(crate) fn is_presented_in(&self, headers: &hyper::HeaderMap) -> bool { headers .get_all(hyper::header::COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|v| v.split(';')) .filter_map(|pair| pair.trim().split_once('=')) .any(|(name, value)| name == self.name && constant_time_eq(value, &self.value)) } } /// Removes every cookie whose name passes `criterion` from the request's /// `Cookie` headers, so the shared app can't read them. pub(crate) fn strip_cookies(headers: &mut hyper::HeaderMap, criterion: impl Fn(&str) -> bool) { use hyper::header::{COOKIE, HeaderValue}; // One `Cookie` header without the stripped `name=value` pairs, or // nothing if that was all it carried. let without_stripped = |header: &HeaderValue| -> Option { let rest = header .to_str() .ok()? .split(';') .map(str::trim) .filter(|pair| { !pair .split_once('=') .is_some_and(|(name, _)| criterion(name)) }) .collect::>() .join("; "); if rest.is_empty() { return None; } HeaderValue::from_str(&rest).ok() }; let kept: Vec = headers .get_all(COOKIE) .iter() .filter_map(without_stripped) .collect(); headers.remove(COOKIE); for value in kept { headers.append(COOKIE, value); } } /// The body of a proxy's responses to the browser. Boxed, so it can be either /// a shared app's body, streamed from the host node, or one the proxy made. pub type BoxedBody = BoxBody; /// After a shared app answered an upgrade request (WebSocket) with 101, relay /// raw bytes both ways between the browser's connection and the stream to the /// host node. Each direction ends at its own EOF: a half-close on one side is /// passed on as a FIN while the other direction keeps flowing, so this returns /// only once both have closed. Both `Upgraded` halves carry any bytes hyper /// buffered past the handshake, so nothing is lost. pub async fn relay_upgraded_connection( browser: hyper::upgrade::OnUpgrade, host_node: hyper::upgrade::OnUpgrade, ) { let (browser, host_node) = match tokio::try_join!(browser, host_node) { Ok(pair) => pair, Err(e) => { warn!( error = format!("{e:#}"), "upgrade handshake did not complete" ); return; } }; let mut browser = TokioIo::new(browser); let mut host_node = TokioIo::new(host_node); if let Err(e) = tokio::io::copy_bidirectional(&mut browser, &mut host_node).await { warn!(error = format!("{e:#}"), "upgraded relay error"); } } /// Remove HTTP/1 hop-by-hop headers (RFC 7230 §6.1). `Transfer-Encoding` is /// handled by hyper itself, so we leave it alone. On an Upgrade exchange, /// `Connection` and `Upgrade` are preserved — they carry the handshake. // // Note: To be fully compliant, this should also process the `Connection` // header's value to remove the headers it names. De facto, `keep-alive` and // `close` are the only headers getting set. pub fn strip_hop_by_hop_headers(headers: &mut hyper::HeaderMap, is_upgrade: bool) { for name in [ "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", ] { headers.remove(name); } if !is_upgrade { headers.remove("connection"); headers.remove("upgrade"); } } /// True if this is an HTTP/1.1 protocol upgrade request (e.g. WebSocket): a /// `Connection` header listing the `upgrade` token plus an `Upgrade` header /// naming the target protocol. pub fn is_protocol_upgrade_request(headers: &hyper::HeaderMap) -> bool { headers.contains_key(hyper::header::UPGRADE) && connection_lists_upgrade(headers) } fn connection_lists_upgrade(headers: &hyper::HeaderMap) -> bool { headers .get(hyper::header::CONNECTION) .and_then(|v| v.to_str().ok()) .is_some_and(|v| { v.split(',') .any(|t| t.trim().eq_ignore_ascii_case("upgrade")) }) } /// The body of a response that has none, such as a 101 or a redirect. pub fn empty_body() -> BoxedBody { Full::new(Bytes::new()) .map_err(|never: Infallible| match never {}) .boxed() } /// The response for a share the host node removed or revoked this device /// from. pub fn gone_response(state: TerminalState) -> hyper::Response { error_response( StatusCode::GONE, &format!( "This app is no longer available on this device — {}.", state.describe() ), ) } /// A response the proxy makes itself, with `msg` as its plain-text body. pub fn error_response(status: hyper::StatusCode, msg: &str) -> hyper::Response { let body: BoxedBody = Full::new(Bytes::copy_from_slice(msg.as_bytes())) .map_err(|never: Infallible| match never {}) .boxed(); hyper::Response::builder() .status(status) .header("content-type", "text/plain; charset=utf-8") .body(body) .expect("static error response is always valid") } /// Whether an error serving the browser's connection just means the browser /// went away (a reload, a closed tab): it closed the connection mid-message, /// or reset it. Routine for a browser, so not worth a warning. pub fn means_browser_went_away(e: &hyper::Error) -> bool { if e.is_incomplete_message() || e.is_body_write_aborted() { return true; } let mut source = std::error::Error::source(e); while let Some(err) = source { if let Some(io) = err.downcast_ref::() { return matches!( io.kind(), std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::NotConnected ); } source = err.source(); } false } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn a_port_taken_on_one_loopback_is_not_half_bound() { // Occupy a port on ::1 only. let Ok(occupant) = TcpListener::bind((Ipv6Addr::LOCALHOST, 0)).await else { eprintln!("no IPv6 loopback on this machine"); return; }; let taken = occupant.local_addr().unwrap().port(); // Asking for that port fails rather than serving IPv4 alone. assert!(bind_port(taken).await.is_err()); // Letting the SDK pick still yields a port bound on both. let (port, listeners) = bind_port(0).await.unwrap(); assert_ne!(port, taken); assert_eq!(listeners.len(), 2); } #[tokio::test] async fn a_port_is_rebound_once_its_holder_lets_go() { let (port, holder) = bind_port(0).await.unwrap(); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(600)).await; drop(holder); }); let (rebound, _listeners) = rebind_port(port).await.unwrap(); assert_eq!(rebound, port); } #[test] fn the_required_cookie_is_found_among_others() { let cookie = RequiredCookie { name: "__wispers_proxy_auth".into(), value: "s3cret".into(), }; let mut headers = hyper::HeaderMap::new(); headers.insert( hyper::header::COOKIE, "a=1; __wispers_proxy_auth=s3cret".parse().unwrap(), ); assert!(cookie.is_presented_in(&headers)); headers.insert( hyper::header::COOKIE, "__wispers_proxy_auth=s3cre".parse().unwrap(), ); assert!(!cookie.is_presented_in(&headers)); assert!(!cookie.is_presented_in(&hyper::HeaderMap::new())); } #[test] fn the_required_cookie_is_stripped_and_the_others_kept() { let mut headers = hyper::HeaderMap::new(); headers.append( hyper::header::COOKIE, "a=1; __wispers_proxy_auth=s3cret; b=2".parse().unwrap(), ); headers.append( hyper::header::COOKIE, "__wispers_proxy_auth=other".parse().unwrap(), ); strip_cookies(&mut headers, |name| name == "__wispers_proxy_auth"); let left: Vec<_> = headers.get_all(hyper::header::COOKIE).iter().collect(); assert_eq!(left, ["a=1; b=2"]); } }