fix(mobile): keep reaching the computer after the gateway restarts

Every `serve` bound a random port, so a restart left each phone holding
addresses that no longer answered. Where the n0 relays are unreachable, which
is common behind the Great Firewall, the phone had no other way to find the
gateway until it was paired again.

- `serve` listens on the same UDP port every time. It picks one on first run,
  keeps it in `<config dir>/mobile/port`, and moves only if the port is taken.
  IPv6 binding may fail, as in iroh's own defaults.
- Both ends add mDNS lookup (`iroh-mdns-address-lookup`, service `_tty7._udp`).
  On the same network a phone finds the gateway by key when its addresses are
  stale, such as after a new DHCP lease or a new IPv6 prefix. The gateway
  advertises and the phone only listens. If multicast is refused, each side
  starts without it and says so, rather than failing.
- iOS: `Info.ios.plist` declares the local-network use and the Bonjour
  service, without which iOS blocks multicast.
- An ignored test (`--test mdns`) connects by key alone over mDNS, for a
  machine that allows multicast.
This commit is contained in:
l0ng-ai
2026-09-27 09:26:18 +02:00
parent 000a43fcb2
commit eedcdd1976
11 changed files with 365 additions and 17 deletions
Generated
+88 -2
View File
@@ -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",
+4
View File
@@ -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.
+64 -9
View File
@@ -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<Daemon>) -> 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<Daemon>) -> 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<u16>) -> Result<Endpoint> {
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();
+25
View File
@@ -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<u16> {
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();
+3
View File
@@ -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]
+22 -6
View File
@@ -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> {
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.
+41
View File
@@ -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();
}
+4
View File
@@ -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;
+14
View File
@@ -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
(`<config dir>/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
+85
View File
@@ -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",
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The app finds a paired computer on the local network by mDNS when the
addresses in its pairing code have changed (tty7-mobile-client::bind).
iOS blocks multicast without both of these. -->
<key>NSLocalNetworkUsageDescription</key>
<string>tty7 looks for your paired computer on this network, so it can reach it after its address changes.</string>
<key>NSBonjourServices</key>
<array>
<string>_tty7._udp</string>
</array>
</dict>
</plist>