diff --git a/Cargo.lock b/Cargo.lock index 35656c9f..65ec17f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,21 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str 0.1.24", + "sync_wrapper", + "tokio", + "tracing", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -1894,7 +1909,7 @@ dependencies = [ "rustc-hash 2.1.3", "self_cell", "skrifa 0.40.0", - "smol_str", + "smol_str 0.3.6", "swash", "sys-locale", "unicode-bidi", @@ -1936,6 +1951,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -3792,7 +3813,7 @@ source = "git+https://github.com/l0ng-ai/zed?branch=tty7#5c390b9359e39f645ed6819 dependencies = [ "schemars", "serde", - "smol_str", + "smol_str 0.3.6", ] [[package]] @@ -4097,6 +4118,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand 0.10.2", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -4795,6 +4834,26 @@ dependencies = [ "url", ] +[[package]] +name = "iroh-mdns-address-lookup" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad3dfcaddd5c3681bf0a42606498fc6e8891de793408dfbc46a9fbe76f370b3" +dependencies = [ + "data-encoding", + "derive_more", + "futures-util", + "iroh", + "iroh-base", + "n0-error", + "n0-future", + "n0-watcher", + "swarm-discovery", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "iroh-metrics" version = "1.0.2" @@ -6842,6 +6901,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -9498,6 +9561,12 @@ dependencies = [ "futures-lite 2.6.1", ] +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + [[package]] name = "smol_str" version = "0.3.6" @@ -9895,6 +9964,21 @@ dependencies = [ "siphasher", ] +[[package]] +name = "swarm-discovery" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4877f20f064de34b77d79237c9caa37c393590af8ef5a2f3253d59be5a338b" +dependencies = [ + "acto", + "hickory-proto", + "rand 0.10.2", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", +] + [[package]] name = "swash" version = "0.2.10" @@ -11109,6 +11193,7 @@ dependencies = [ "clap", "getrandom 0.3.4", "iroh", + "iroh-mdns-address-lookup", "libc", "qrcode", "serde", @@ -11126,6 +11211,7 @@ version = "26.9.3" dependencies = [ "anyhow", "iroh", + "iroh-mdns-address-lookup", "serde", "serde_json", "tokio", diff --git a/crates/tty7-gateway/Cargo.toml b/crates/tty7-gateway/Cargo.toml index 8de5aeba..0bb4592e 100644 --- a/crates/tty7-gateway/Cargo.toml +++ b/crates/tty7-gateway/Cargo.toml @@ -24,6 +24,10 @@ clap = { version = "4", features = ["derive"] } # cannot — so a phone on cellular reaches a laptop behind a home router with no # port forwarding and no VPN, end-to-end encrypted either way. iroh = "1.2" +# Lets a phone on the same network find the gateway by its key when the +# addresses in its pairing code have gone stale — a new DHCP lease, a new IPv6 +# prefix — with no relay reachable to ask instead. +iroh-mdns-address-lookup = "0.5" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util"] } getrandom = "0.3" # Draws the pairing code as a QR code right in the terminal. diff --git a/crates/tty7-gateway/src/main.rs b/crates/tty7-gateway/src/main.rs index 5fc7dd8c..5b3ff76c 100644 --- a/crates/tty7-gateway/src/main.rs +++ b/crates/tty7-gateway/src/main.rs @@ -3,12 +3,15 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use clap::{Parser, Subcommand}; -use iroh::Endpoint; -use iroh::endpoint::presets; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +use iroh::endpoint::{BindOpts, presets}; +use iroh::{Endpoint, SecretKey}; +use iroh_mdns_address_lookup::MdnsAddressLookup; use tty7_gateway::daemon::{Daemon, hostname}; use tty7_gateway::serve::{self, Backend as _}; use tty7_gateway::state::{Reachable, State}; -use tty7_mobile_proto::{ALPN, PairCode}; +use tty7_mobile_proto::{ALPN, MDNS_SERVICE, PairCode}; #[derive(Parser)] #[command( @@ -82,12 +85,16 @@ fn main() -> Result<()> { } async fn serve_forever(state: State, daemon: Arc) -> Result<()> { - let endpoint = Endpoint::builder(presets::N0) - .secret_key(state.secret_key()?) - .alpns(vec![ALPN.to_vec()]) - .bind() - .await - .context("starting the iroh endpoint")?; + let endpoint = bind(state.secret_key()?, state.port()).await?; + if let Some(port) = endpoint + .bound_sockets() + .iter() + .map(SocketAddr::port) + .find(|&p| p != 0) + && state.port() != Some(port) + { + state.set_port(port).context("remembering the port")?; + } println!("tty7-gateway: listening as {}", endpoint.id()); // Keep the addresses `pair` puts in a code current. The local ones are @@ -116,6 +123,54 @@ async fn serve_forever(state: State, daemon: Arc) -> Result<()> { Ok(()) } +/// Binds the gateway's endpoint on the port it had last time, so the +/// addresses in phones' pairing codes still reach it, and advertises it on the +/// local network. Each of those is given up, with a note, rather than let it +/// keep the gateway from starting: the port may be taken, and multicast may be +/// off. +async fn bind(key: SecretKey, port: Option) -> Result { + let mut attempts = Vec::new(); + if let Some(port) = port { + attempts.extend([(port, true), (port, false)]); + } + attempts.extend([(0, true), (0, false)]); + + let mut failures = Vec::new(); + for (port, mdns) in attempts { + let builder = Endpoint::builder(presets::N0) + .secret_key(key.clone()) + .alpns(vec![ALPN.to_vec()]) + .clear_ip_transports() + .bind_addr(SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)))? + // IPv6 is a bonus, as it is in iroh's own defaults. + .bind_addr_with_opts( + SocketAddr::from((Ipv6Addr::UNSPECIFIED, port)), + BindOpts::default().set_is_required(false), + )?; + let builder = match mdns { + true => builder.address_lookup(MdnsAddressLookup::builder().service_name(MDNS_SERVICE)), + false => builder, + }; + match builder.bind().await { + Ok(endpoint) => { + for failure in failures { + eprintln!("tty7-gateway: {failure} — carrying on without it"); + } + return Ok(endpoint); + } + Err(e) => { + let what = match (port, mdns) { + (0, true) => "local network discovery".to_string(), + (0, false) => "the iroh endpoint".to_string(), + (port, _) => format!("port {port}"), + }; + failures.push(format!("{what}: {e}")); + } + } + } + anyhow::bail!("could not start: {}", failures.join("; ")) +} + fn pair(state: &State, ttl: u64) -> Result<()> { let host_id = state.secret_key()?.public().to_string(); let reachable = state.reachable(); diff --git a/crates/tty7-gateway/src/state.rs b/crates/tty7-gateway/src/state.rs index 5e4f94c9..27633c0f 100644 --- a/crates/tty7-gateway/src/state.rs +++ b/crates/tty7-gateway/src/state.rs @@ -21,6 +21,7 @@ const DEVICES_FILE: &str = "devices.json"; const PAIRING_FILE: &str = "pairing.json"; const ADDR_FILE: &str = "addr.json"; const LOCK_FILE: &str = "serve.lock"; +const PORT_FILE: &str = "port"; #[derive(Debug, Clone)] pub struct State { @@ -194,6 +195,22 @@ impl State { pairing.expires_at >= unix_now() && constant_time_eq(&pairing.secret, secret) } + /// The UDP port `serve` last listened on. A phone dials the addresses in + /// its pairing code first, so keeping the port keeps those addresses good + /// across restarts. + pub fn port(&self) -> Option { + fs::read_to_string(self.dir.join(PORT_FILE)) + .ok()? + .trim() + .parse() + .ok() + .filter(|&port| port != 0) + } + + pub fn set_port(&self, port: u16) -> Result<()> { + write_private(&self.dir.join(PORT_FILE), port.to_string().as_bytes()) + } + pub fn reachable(&self) -> Reachable { read_json(&self.dir.join(ADDR_FILE)) .ok() @@ -324,6 +341,14 @@ mod tests { .expect("free again once the holder is gone"); } + #[test] + fn the_port_is_kept() { + let (_tmp, state) = state(); + assert_eq!(state.port(), None); + state.set_port(41641).unwrap(); + assert_eq!(state.port(), Some(41641)); + } + #[test] fn devices_are_added_replaced_and_revoked() { let (_tmp, state) = state(); diff --git a/crates/tty7-mobile-client/Cargo.toml b/crates/tty7-mobile-client/Cargo.toml index 5a99dd1a..7ebd33e7 100644 --- a/crates/tty7-mobile-client/Cargo.toml +++ b/crates/tty7-mobile-client/Cargo.toml @@ -15,10 +15,13 @@ tty7-mobile-proto = { path = "../tty7-mobile-proto", features = ["tokio"] } anyhow.workspace = true serde = { workspace = true } iroh = "1.2" +# Finds a paired gateway on the local network after its addresses change. +iroh-mdns-address-lookup = "0.5" tokio = { version = "1", features = ["sync", "time", "io-util"] } [dev-dependencies] serde_json.workspace = true +tty7-mobile-proto = { path = "../tty7-mobile-proto" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } [lints] diff --git a/crates/tty7-mobile-client/src/lib.rs b/crates/tty7-mobile-client/src/lib.rs index bd163121..cb7963e5 100644 --- a/crates/tty7-mobile-client/src/lib.rs +++ b/crates/tty7-mobile-client/src/lib.rs @@ -11,10 +11,11 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use iroh::endpoint::{Connection, RecvStream, SendStream, presets}; use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl, SecretKey}; +use iroh_mdns_address_lookup::MdnsAddressLookup; use serde::{Deserialize, Serialize}; use tty7_mobile_proto::{ - ALPN, ControlEvent, ControlRequest, Frame, GridSize, Open, OpenReply, PROTOCOL_VERSION, - PairCode, PaneEvent, TabCreated, read_frame, write_bytes, write_msg, + ALPN, ControlEvent, ControlRequest, Frame, GridSize, MDNS_SERVICE, Open, OpenReply, + PROTOCOL_VERSION, PairCode, PaneEvent, TabCreated, read_frame, write_bytes, write_msg, }; /// How long to wait for a gateway to answer an [`Open`]. @@ -52,13 +53,28 @@ impl Host { } } -/// Binds the phone's endpoint with iroh's public relays and address lookup. +/// Binds the phone's endpoint with iroh's public relays and address lookup, +/// plus mDNS: on the gateway's own network that finds it by key even when the +/// addresses the phone knows are stale and no relay can be reached. The phone +/// only listens; it has no reason to announce itself. Where multicast is not +/// allowed it does without. pub async fn bind(secret: SecretKey) -> Result { - Endpoint::builder(presets::N0) - .secret_key(secret) + let mdns = MdnsAddressLookup::builder() + .service_name(MDNS_SERVICE) + .advertise(false); + match Endpoint::builder(presets::N0) + .secret_key(secret.clone()) + .address_lookup(mdns) .bind() .await - .context("starting the iroh endpoint") + { + Ok(endpoint) => Ok(endpoint), + Err(_) => Endpoint::builder(presets::N0) + .secret_key(secret) + .bind() + .await + .context("starting the iroh endpoint"), + } } /// Trades a pairing code for a [`Host`] the app can store and dial later. diff --git a/crates/tty7-mobile-client/tests/mdns.rs b/crates/tty7-mobile-client/tests/mdns.rs new file mode 100644 index 00000000..4e0c51b9 --- /dev/null +++ b/crates/tty7-mobile-client/tests/mdns.rs @@ -0,0 +1,41 @@ +//! A phone finds a gateway on the local network by key alone, with none of +//! its addresses known and no relay or DNS to ask — what happens when every +//! address in the pairing code has gone stale. +//! +//! Ignored by default: it needs multicast on the machine's network, which CI +//! runners and sandboxes may not allow. `cargo test -p tty7-mobile-client +//! --test mdns -- --ignored`. + +use std::time::Duration; + +use iroh::endpoint::presets; +use iroh::{Endpoint, EndpointAddr, SecretKey}; +use iroh_mdns_address_lookup::MdnsAddressLookup; +use tty7_mobile_proto::{ALPN, MDNS_SERVICE}; + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs multicast on the local network"] +async fn a_gateway_is_found_by_key_on_the_local_network() { + let gateway = Endpoint::builder(presets::Minimal) + .secret_key(SecretKey::generate()) + .alpns(vec![ALPN.to_vec()]) + .address_lookup(MdnsAddressLookup::builder().service_name(MDNS_SERVICE)) + .bind() + .await + .unwrap(); + let accept = tokio::spawn({ + let gateway = gateway.clone(); + async move { gateway.accept().await.unwrap().await.unwrap() } + }); + + let phone = tty7_mobile_client::bind(SecretKey::generate()) + .await + .unwrap(); + let only_the_key = EndpointAddr::new(gateway.id()); + let conn = tokio::time::timeout(Duration::from_secs(20), phone.connect(only_the_key, ALPN)) + .await + .expect("found within 20 s") + .expect("connected"); + assert_eq!(conn.remote_id(), gateway.id()); + accept.await.unwrap(); +} diff --git a/crates/tty7-mobile-proto/src/lib.rs b/crates/tty7-mobile-proto/src/lib.rs index 8b945c5c..971e4699 100644 --- a/crates/tty7-mobile-proto/src/lib.rs +++ b/crates/tty7-mobile-proto/src/lib.rs @@ -30,6 +30,10 @@ use serde::{Deserialize, Serialize}; /// during the handshake, before a byte of this protocol is read. pub const ALPN: &[u8] = b"tty7/mobile/1"; +/// The mDNS service both ends use to find each other on a local network, +/// whatever addresses the gateway has moved to since pairing. +pub const MDNS_SERVICE: &str = "tty7"; + /// Bumped when a message changes shape. Carried in [`OpenReply::Ok`] so an app /// can tell the user to update rather than misread a newer gateway. pub const PROTOCOL_VERSION: u32 = 1; diff --git a/mobile/README.md b/mobile/README.md index a668f5a6..41a576f6 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -75,6 +75,20 @@ cargo run -p tty7-mobile-client --example probe -- tree cargo run -p tty7-mobile-client --example probe -- type 1 'echo hi' ``` +## Finding the computer again + +A pairing code carries the gateway's addresses at the time it was made. Those +stay good across restarts: `serve` listens on the same UDP port every time +(`/mobile/port`, a fresh one only if it is taken). When they go stale +anyway — a new DHCP lease, a new IPv6 prefix — the phone looks the gateway up by +key: through n0's relay and DNS where those are reachable, and by mDNS +(`_tty7._udp`) on the same local network where they are not. + +mDNS needs the OS's local-network permission on both ends. On macOS that belongs +to whatever launched the gateway (your terminal), and it answers "Allow … to find +devices on local networks" once. On iOS the app declares it in +`src-tauri/Info.ios.plist`. Without it, known addresses and the relay still work. + ## Not done yet - **QR scanning.** The app takes a pasted code for now. Next step is diff --git a/mobile/src-tauri/Cargo.lock b/mobile/src-tauri/Cargo.lock index 66f4fc30..1a04e177 100644 --- a/mobile/src-tauri/Cargo.lock +++ b/mobile/src-tauri/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str", + "sync_wrapper", + "tokio", + "tracing", +] + [[package]] name = "adler2" version = "2.0.1" @@ -605,6 +620,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1722,6 +1743,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2169,6 +2208,26 @@ dependencies = [ "url", ] +[[package]] +name = "iroh-mdns-address-lookup" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad3dfcaddd5c3681bf0a42606498fc6e8891de793408dfbc46a9fbe76f370b3" +dependencies = [ + "data-encoding", + "derive_more", + "futures-util", + "iroh", + "iroh-base", + "n0-error", + "n0-future", + "n0-watcher", + "swarm-discovery", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "iroh-metrics" version = "1.0.2" @@ -3295,6 +3354,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "opaque-debug" @@ -4429,6 +4492,12 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + [[package]] name = "socket2" version = "0.6.5" @@ -4583,6 +4652,21 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swarm-discovery" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4877f20f064de34b77d79237c9caa37c393590af8ef5a2f3253d59be5a338b" +dependencies = [ + "acto", + "hickory-proto", + "rand", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", +] + [[package]] name = "swift-rs" version = "1.0.8" @@ -5418,6 +5502,7 @@ version = "26.9.3" dependencies = [ "anyhow", "iroh", + "iroh-mdns-address-lookup", "serde", "tokio", "tty7-mobile-proto", diff --git a/mobile/src-tauri/Info.ios.plist b/mobile/src-tauri/Info.ios.plist new file mode 100644 index 00000000..88e48954 --- /dev/null +++ b/mobile/src-tauri/Info.ios.plist @@ -0,0 +1,15 @@ + + + + + + NSLocalNetworkUsageDescription + tty7 looks for your paired computer on this network, so it can reach it after its address changes. + NSBonjourServices + + _tty7._udp + + +