From 672d828ef58c9106f81ccccb53e7812ff9164306 Mon Sep 17 00:00:00 2001 From: Arjen <18398758+Origami74@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:28:22 +0100 Subject: [PATCH] feat(node): publish the DNS responder's bound address for embedders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An embedder that owns the TUN fd has no system DNS socket to point at the built-in `.fips` responder. On Android specifically, `VpnService.Builder` exposes `addDnsServer(address)` with no port — the OS resolver always uses 53, which an unprivileged app UID cannot bind — and it aims the resolver *into* the tunnel, so `.fips` queries surface as IPv6/UDP packets on the app's own fd rather than at any socket FIPS holds. The app can still use the responder rather than reimplementing resolution: lift the DNS payload out of the packet it read, send it to the responder over an ordinary UDP socket of its own, and splice the answer back into a reply packet. Nothing in the responder's start-up is desktop-specific — `bind_dns_socket` is plain socket2, `lookup_mesh_ifindex` returns None with no system TUN so the mesh filter self-disables, and `HostMapReloader` on an absent hosts file settles at a no-op stat. What was missing is the address to dial and whether anything is listening at it. `dns_local_addr()` answers both, as a one-shot read taken after `start()` returns and before the node is moved into a background task. That is the only window in which an embedder running `run_rx_loop` holds a `&Node` to call it on, and the value is settled by then: the responder is either up for the rest of the node's life or it never came up. It reports the address read back off the bound socket, so a `dns.port = 0` config yields the port the kernel assigned rather than 0. Config alone cannot answer the second question — `dns.enabled` with a failed bind leaves `bind_addr` naming a plausible target nothing is listening on, and a bind failure only warns rather than failing node start. It is deliberately not a liveness feed. Watching a responder that dies later needs a way to read live node state from a backgrounded `run_rx_loop`, which is a general gap and not one an accessor should try to close. Routing through the responder rather than resolving in the app is what keeps route warming intact. Answering a `.fips` query is what puts that peer's public key in the node's identity cache, and a FipsAddress is SHA-256(pubkey) truncated twice: the key cannot be recovered from the IPv6 address. With no cache entry the first packet to a freshly-resolved name is rejected with ICMPv6 "No route" — a failure that direct neighbours mask entirely, since their identity arrives with the Noise handshake and never needed resolving. The address is retracted on `stop()`. `retract_child_publications` also handles a responder that exits on its own at runtime, where the FSM's `ChildExited` handling republishes node health but touches no per-child handles. That consumer is dormant as written and documented as such: `run_dns_responder` is an unconditional loop whose every failure arm continues, so it never returns and the `Child::Dns` send after it is unreachable. It lands here so a producer fix does not have to rediscover the consuming side. A panicking responder is not covered either way, since the unwind goes past the send rather than through it — true of every child producer, not just this one. The IPv6-adapter design doc gains an App-Owned DNS Path section beside the App-Owned TUN one it mirrors, plus implementation-status rows for both. Three tests. `dns_responder_serves_a_proxying_embedder` is the load-bearing one: port-0 read-back, a proxied query answered with the right AAAA, and the resolved identity arriving on the channel `run_rx_loop` drains into `register_identity`. `dns_local_addr_stays_none_when_the_bind_fails` forces `EADDRINUSE` against a socket the test holds open — `bind_dns_socket` sets neither `SO_REUSEADDR` nor `SO_REUSEPORT`, so that is deterministic, where naming a non-local address is not: `net.ipv4.ip_nonlocal_bind = 1` is ordinary on hosts running keepalived or HAProxy and makes the bind succeed. `retract_child_publications_clears_the_dns_address` is scoped and named for the helper rather than the scenario, because deleting the `run_rx_loop` call site leaves it green; that wiring is covered by nothing. Full suite 1672 passed, 0 failed. fmt, `clippy --all-targets -D warnings` and the Android `cargo ndk clippy --lib -D warnings` gate are clean. The changelog entry was added at merge rather than in the pull request: the app-owned TUN seam it mirrors gained an Unreleased entry in the master-only sweep, so this one would otherwise recreate that debt. --- CHANGELOG.md | 8 ++ docs/design/fips-ipv6-adapter.md | 43 +++++++ src/node/dataplane/rx_loop.rs | 5 + src/node/lifecycle/mod.rs | 41 ++++++- src/node/lifecycle/supervisor.rs | 6 + src/node/mod.rs | 50 ++++++++ src/node/tests/unit.rs | 193 +++++++++++++++++++++++++++++++ 7 files changed, 345 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b82fb51..bc6c0681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `fd00::/8`-destined packets and clamp TCP MSS on outbound SYNs. Desktop builds are unchanged and no Cargo features are introduced. +- `Node::dns_local_addr()`, the DNS companion to the app-owned TUN seam above. + An embedder whose resolver is pointed into the tunnel has no system socket + aimed at the built-in `.fips` responder, so the accessor reports the address + read back off the bound socket: `dns.port = 0` therefore yields the + kernel-assigned port, and it returns `Some` only while the responder is up. + Read it once, after `start()` returns and before the node is moved into a + background task; it is not a liveness feed (#136). + - A bounded graceful-shutdown drain phase, controlled by the new `node.drain_timeout_secs` (default 2s). On the shutdown signal the node broadcasts Disconnect to all peers and then keeps serving for that window, diff --git a/docs/design/fips-ipv6-adapter.md b/docs/design/fips-ipv6-adapter.md index 06ad35a6..f86cbae9 100644 --- a/docs/design/fips-ipv6-adapter.md +++ b/docs/design/fips-ipv6-adapter.md @@ -330,6 +330,47 @@ and the [TUN-Side TCP MSS Clamping](#tun-side-tcp-mss-clamping). The embedder is therefore responsible for routing only `fd00::/8` to its TUN (so only mesh-bound packets arrive) and for clamping TCP MSS on outbound SYNs. +### App-Owned DNS Path (embedded hosts) + +An embedded host that owns the TUN fd generally has no system DNS socket to aim +at the responder either. On Android, `VpnService.Builder.addDnsServer()` takes an +address with no port — the resolver always uses 53, which an unprivileged app UID +cannot bind — and it points the resolver *into* the tunnel, so `.fips` queries +surface as IPv6/UDP packets on the app's own fd rather than at any socket FIPS +holds. The [DNS responder](#dns-integration) itself needs no changes for this: +its bind is a plain UDP socket, and with no system TUN the +[mesh-interface filter](#mesh-interface-query-filter) self-disables because the +interface name does not resolve. + +`Node::dns_local_addr()` closes the gap. It reports the address read back off the +bound socket — so a `dns.port = 0` config yields the port the kernel assigned — +and is `None` when no responder came up. The embedder lifts the DNS payload out +of the packet it read, sends it to that address over an ordinary UDP socket of +its own, and splices the answer back into a reply packet. + +It is a one-shot read taken after `start()` returns and before the node is moved +into a background task, because `run_rx_loop` then borrows the node exclusively +for its whole lifetime and no `&Node` remains to call it on. By that point the +value is settled: the responder is either up for the rest of the node's life or +it never came up. + +Proxying to the responder rather than resolving in the app is what keeps the +[identity cache](#identity-cache) warm. Answering a `.fips` query is what +registers that peer's public key, and a FIPS address is a truncated hash of a +hash of the pubkey — the key cannot be recovered from the IPv6 address alone. An +app that resolves the AAAA itself leaves the cache empty, and the first packet to +the resolved name is rejected with ICMPv6 Destination Unreachable. Direct +neighbours mask the omission, since their identity arrives with the Noise +handshake and never needed resolving. + +The address is retracted on `stop()`. A retraction hook for a responder that +exits on its own at runtime is wired on the consuming side, but is dormant: +`run_dns_responder` never returns, so nothing produces the `Child::Dns` exit +event it consumes. Watching a responder that dies mid-run is therefore not +something an embedder can do today, and would in any case need a way to read +live node state from a backgrounded `run_rx_loop` — a general gap rather than a +DNS-specific one. + ## Implementation Status | Feature | Status | @@ -343,6 +384,8 @@ packets arrive) and for clamping TCP MSS on outbound SYNs. | TCP MSS clamping (SYN + SYN-ACK) | **Implemented** | | DNS service (.fips domain) | **Implemented** | | DNS responder mesh-interface filter | **Implemented** | +| App-owned TUN (`Node::enable_app_owned_tun`) | **Implemented** | +| App-owned DNS path (`Node::dns_local_addr`) | **Implemented** | | Port-based service multiplexing (port 256) | **Implemented** | | IPv6 header compression (format 0x00) | **Implemented** | | Per-destination route MTU (netlink) | Planned | diff --git a/src/node/dataplane/rx_loop.rs b/src/node/dataplane/rx_loop.rs index 0a9bdd28..75ec0797 100644 --- a/src/node/dataplane/rx_loop.rs +++ b/src/node/dataplane/rx_loop.rs @@ -271,6 +271,11 @@ impl Node { // `PublishState`; other variants are ignored defensively. maybe_child = child_exit_rx.recv() => { if let Some(child) = maybe_child { + // Drop anything the dead child published for embedders + // (e.g. the DNS responder's bound address) before + // republishing health, so nothing outside the node can + // observe an address the listener no longer answers on. + self.retract_child_publications(child); let actions = self .supervisor .fsm diff --git a/src/node/lifecycle/mod.rs b/src/node/lifecycle/mod.rs index ef30bccf..831bef32 100644 --- a/src/node/lifecycle/mod.rs +++ b/src/node/lifecycle/mod.rs @@ -1747,6 +1747,12 @@ impl Node { let bind = std::net::SocketAddr::new(ip, self.config().dns.port()); match Self::bind_dns_socket(bind) { Ok(socket) => { + // Read the bound address back off the socket + // rather than reusing `bind`: a port-0 config + // resolves to the kernel-assigned port here, + // and this is the address an embedder that + // proxies queries to us has to dial. + let local_addr = socket.local_addr().unwrap_or(bind); let dns_channel_size = self.config().node.buffers.dns_channel; let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(dns_channel_size); @@ -1771,7 +1777,7 @@ impl Node { let mesh_ifindex = Self::lookup_mesh_ifindex(self.config().tun.name()); info!( - bind = %bind, + bind = %local_addr, hosts = reloader.hosts().len(), mesh_ifindex = ?mesh_ifindex, "DNS responder started for .fips domain (auto-reload enabled)" @@ -1797,6 +1803,7 @@ impl Node { }); self.supervisor.dns_identity_rx = Some(identity_rx); self.supervisor.dns_task = Some(handle); + self.supervisor.dns_local_addr = Some(local_addr); Event::SubstrateUp { child } } Err(e) => { @@ -2057,6 +2064,10 @@ impl Node { handle.abort(); debug!("DNS responder stopped"); } + // Retract the published address in the same step that kills + // the listener, so an embedder polling `dns_local_addr()` + // never dials a socket that is already gone. + self.supervisor.dns_local_addr.take(); } Child::Nostr => { // Stop Nostr overlay discovery background work and withdraw @@ -2153,6 +2164,34 @@ impl Node { } } + /// Retract anything a child published about itself, after it exited on its + /// own at runtime (as opposed to being torn down by [`Self::stop`]). + /// + /// The FSM's `ChildExited` handling only republishes node health; it does + /// not touch per-child handles. That is fine for state nobody outside the + /// node reads, but not for an address an embedder dials: a stale + /// [`Node::dns_local_addr`] would have the app proxying `.fips` queries + /// into a socket that is gone, and the only symptom would be resolution + /// quietly timing out. + /// + /// Deliberately narrow — it clears published facts, not handles. + /// `dns_task` is left alone because [`Self::reconstruct_supervised_up`] + /// reads it to rebuild the teardown set, and aborting an already-finished + /// handle there is harmless. + /// + /// **Dormant for `Dns` as written.** `run_dns_responder` is an unconditional + /// loop whose every failure arm continues, so it never returns and the + /// `Child::Dns` send that follows it is unreachable — nothing produces the + /// event this consumes. The consumer side is correct and lands here so the + /// producer fix does not have to rediscover it. A responder that *panics* is + /// not covered either way, since the unwind goes past the send rather than + /// through it; that is true of every child producer, not just this one. + pub(in crate::node) fn retract_child_publications(&mut self, child: Child) { + if matches!(child, Child::Dns) { + self.supervisor.dns_local_addr.take(); + } + } + /// Reconstruct the supervised up-set from observed runtime presence, so the /// FSM authors the teardown order regardless of how the node reached /// `Running`. Worker pools are deliberately excluded: today's teardown never diff --git a/src/node/lifecycle/supervisor.rs b/src/node/lifecycle/supervisor.rs index 9fc1913f..0f3c39cf 100644 --- a/src/node/lifecycle/supervisor.rs +++ b/src/node/lifecycle/supervisor.rs @@ -692,6 +692,11 @@ pub(crate) struct Supervisor { pub(in crate::node) dns_identity_rx: Option, /// DNS responder task handle. pub(in crate::node) dns_task: Option>, + /// Address the DNS responder actually bound, read back from the socket + /// after `bind` so a port-0 config resolves to the assigned port. `Some` + /// only while the responder is up; published to embedders through + /// [`Node::dns_local_addr`](crate::Node::dns_local_addr). + pub(in crate::node) dns_local_addr: Option, /// Node-side driver state for the Nostr overlay peer-rendezvous /// subsystem: the engine handle, its startup timestamp, the one-shot @@ -737,6 +742,7 @@ impl Supervisor { tun_shutdown_fd: None, dns_identity_rx: None, dns_task: None, + dns_local_addr: None, nostr_rendezvous: crate::nostr::RendezvousDriver::default(), lan_rendezvous: None, #[cfg(unix)] diff --git a/src/node/mod.rs b/src/node/mod.rs index 20bda80c..f395b4fd 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -2874,6 +2874,56 @@ impl Node { (outbound_tx, tun_rx) } + /// Address the built-in `.fips` DNS responder is listening on, or `None` + /// when it is not running (`dns.enabled = false`, the bind failed, or the + /// node is stopped). + /// + /// This is the companion to [`Self::enable_app_owned_tun`] for embedders + /// that own the TUN fd. On a platform with no system DNS socket to point + /// at us — an Android `VpnService`, whose `addDnsServer()` takes an address + /// with no port and aims the OS resolver *into* the tunnel — `.fips` + /// queries arrive as IPv6/UDP packets on the app's own fd. The app can + /// forward the DNS payload here and splice the answer back into a reply + /// packet, rather than reimplementing resolution: + /// + /// ```no_run + /// # async fn f(node: &fips::Node, query: &[u8]) -> std::io::Result<()> { + /// let Some(dns) = node.dns_local_addr() else { return Ok(()) }; + /// let sock = tokio::net::UdpSocket::bind("[::1]:0").await?; + /// sock.send_to(query, dns).await?; // payload only, no IP/UDP header + /// let mut answer = [0u8; 512]; + /// let (n, _) = sock.recv_from(&mut answer).await?; + /// # let _ = n; Ok(()) + /// # } + /// ``` + /// + /// Going through the responder rather than resolving in the app is what + /// keeps route warming working: answering a `.fips` query is what + /// populates the node's identity cache with that peer's public key, and a + /// `FipsAddress` is a truncated hash — the pubkey cannot be recovered from + /// the IPv6 address alone. Without a cache entry the first packet to a + /// freshly-resolved name is rejected with ICMPv6 "No route". Direct + /// neighbours mask this, since their identity comes from the Noise + /// handshake and never needed resolving. + /// + /// Read this **once, after [`Self::start`] returns and before the node is + /// moved into a background task** — that is the only window in which an + /// embedder running [`Self::run_rx_loop`] holds a `&Node` to call it on, and + /// the value is fixed by then: the responder is either up for the rest of + /// the node's life or it never came up. The address is read back off the + /// bound socket, so a `dns.port = 0` config reports the port the kernel + /// actually assigned. + /// + /// This is a one-shot read, not a liveness feed. `None` distinguishes "no + /// responder" from "responder at this address" at that moment; it is not a + /// signal an embedder can watch for a responder that dies later, because + /// `run_rx_loop` borrows the node exclusively for its whole lifetime. + /// Reading live node state from a backgrounded loop is a general gap, not + /// one this accessor tries to close. + pub fn dns_local_addr(&self) -> Option { + self.supervisor.dns_local_addr + } + // === Sending === /// Encrypt and send a link-layer message to an authenticated peer. diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 1af6c590..a223b130 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -2798,6 +2798,199 @@ async fn start_skips_system_tun_when_app_owned() { node.stop().await.unwrap(); } +/// The embedder-facing DNS contract, end to end. +/// +/// An embedder that owns the TUN fd (Android `VpnService`) has no system DNS +/// socket to point at us, so it proxies `.fips` query payloads it lifts out of +/// its own tunnel to the built-in responder. That requires three things to +/// hold, and this pins all three: +/// +/// 1. `dns_local_addr()` publishes where to send — read back off the bound +/// socket, so a `port = 0` config reports the assigned port, not 0. +/// 2. The responder answers a proxied query with the right AAAA. +/// 3. The resolved identity reaches `dns_identity_rx` — the channel +/// `run_rx_loop` drains into `register_identity`. This is the leg that +/// populates the identity cache, without which the first packet to a +/// freshly-resolved `.fips` is rejected with ICMPv6 "No route". +#[tokio::test] +async fn dns_responder_serves_a_proxying_embedder() { + let mut config = crate::Config::new(); + config.transports.udp = crate::config::TransportInstances::Single(crate::config::UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + ..Default::default() + }); + config.dns.enabled = true; + config.dns.bind_addr = Some("::1".to_string()); + // Port 0: proves the address is read back off the socket rather than + // echoed from config — an embedder dialling 0 would reach nothing. + config.dns.port = Some(0); + // The TUN is app-owned, as it is on the platform this seam serves. + let mut node = make_node_with(config); + let (_outbound_tx, _tun_rx) = node.enable_app_owned_tun(); + + assert!( + node.dns_local_addr().is_none(), + "no responder before start()", + ); + + node.start().await.unwrap(); + + let dns_addr = node + .dns_local_addr() + .expect("responder is up, so its address is published"); + assert_ne!(dns_addr.port(), 0, "must report the kernel-assigned port"); + + // Proxy a query the way the embedder would: payload only, no IP/UDP header + // (it strips those off the packet it read from its own TUN fd). + let peer = Identity::generate(); + let query = { + use simple_dns::{CLASS, Name, Packet, QCLASS, QTYPE, Question, TYPE}; + let mut packet = Packet::new_query(0x1234); + packet.questions.push(Question::new( + Name::new_unchecked(&format!("{}.fips", peer.npub())).into_owned(), + QTYPE::TYPE(TYPE::AAAA), + QCLASS::CLASS(CLASS::IN), + false, + )); + packet.build_bytes_vec().unwrap() + }; + let client = tokio::net::UdpSocket::bind("[::1]:0").await.unwrap(); + client.send_to(&query, dns_addr).await.unwrap(); + + let mut buf = [0u8; 512]; + let (len, _) = tokio::time::timeout( + std::time::Duration::from_secs(2), + client.recv_from(&mut buf), + ) + .await + .expect("responder answered within the timeout") + .unwrap(); + + let answer = simple_dns::Packet::parse(&buf[..len]).expect("well-formed DNS response"); + let rdata = &answer.answers.first().expect("one AAAA answer").rdata; + let simple_dns::rdata::RData::AAAA(aaaa) = rdata else { + panic!("expected an AAAA record, got {rdata:?}"); + }; + assert_eq!( + std::net::Ipv6Addr::from(aaaa.address), + peer.address().to_ipv6(), + "AAAA must be the peer's FipsAddress", + ); + + // The identity leg. `run_rx_loop` owns the node for its whole life, so the + // embedder cannot register identities itself — the responder publishes them + // on this channel instead. Drain and register exactly as the rx-loop arm in + // `dataplane/rx_loop.rs` does, then assert the cache is populated. + let identity = tokio::time::timeout( + std::time::Duration::from_secs(2), + node.supervisor + .dns_identity_rx + .as_mut() + .expect("responder installed the identity receiver") + .recv(), + ) + .await + .expect("identity published within the timeout") + .expect("channel is open"); + + assert_eq!(identity.node_addr, *peer.node_addr()); + node.register_identity(identity.node_addr, identity.pubkey); + assert!( + node.has_cached_identity(peer.node_addr()), + "resolving a name must warm the identity cache, or the first packet \ + to that address is rejected with ICMPv6 \"No route\"", + ); + + node.stop().await.unwrap(); + assert!( + node.dns_local_addr().is_none(), + "the published address must be retracted with the listener", + ); +} + +/// `retract_child_publications(Dns)` clears the published address. +/// +/// Scoped to the helper deliberately, and named for that rather than for the +/// scenario: no responder dies here, and deleting the `run_rx_loop` call site +/// leaves this green. Driving a real exit through the loop needs the node moved +/// into a task, which puts `dns_local_addr()` out of reach — and the producer +/// side cannot deliver `Child::Dns` today regardless, since `run_dns_responder` +/// never returns. +/// +/// What it does pin is the behavior the eventual wiring depends on: the FSM's +/// `ChildExited` handling only republishes node health, so without this +/// retraction `dns_local_addr()` would keep naming a socket nobody is listening +/// on, and a proxying embedder would see `.fips` queries silently time out +/// rather than any error it could act on. +#[tokio::test] +async fn retract_child_publications_clears_the_dns_address() { + let mut config = crate::Config::new(); + config.transports.udp = crate::config::TransportInstances::Single(crate::config::UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + ..Default::default() + }); + config.dns.enabled = true; + config.dns.bind_addr = Some("::1".to_string()); + config.dns.port = Some(0); + let mut node = make_node_with(config); + + node.start().await.unwrap(); + assert!(node.dns_local_addr().is_some(), "responder came up"); + + // What `run_rx_loop` does when the DNS task self-reports its exit. + node.retract_child_publications(crate::node::lifecycle::supervisor::Child::Dns); + + assert!( + node.dns_local_addr().is_none(), + "a dead responder must not keep publishing an address to dial", + ); + + node.stop().await.unwrap(); +} + +/// `dns.enabled` with a bind that fails must report `None`, not an address. +/// +/// This is the third state an embedder has to tell apart, and the one that +/// would otherwise be indistinguishable from a healthy responder by reading +/// config alone: DNS is switched on, so `config.dns.bind_addr()` names a +/// plausible target, but nothing is listening there. A bind failure is only +/// warned about and leaves the node running, so config is not evidence — +/// `dns_local_addr()` is. +/// +/// The failure is forced with `EADDRINUSE` against a socket this test holds +/// open, rather than by naming an address the host has no interface for. +/// `bind_dns_socket` sets neither `SO_REUSEADDR` nor `SO_REUSEPORT`, so the +/// collision is deterministic on Linux and macOS. A non-local address is not: +/// `net.ipv4.ip_nonlocal_bind = 1` is ordinary on hosts running keepalived or +/// HAProxy and makes the bind succeed, which reds the test on a developer +/// machine while CI — at the default `0` — stays green. +#[tokio::test] +async fn dns_local_addr_stays_none_when_the_bind_fails() { + // Hold the port for the whole test so the responder's bind collides. + let squatter = tokio::net::UdpSocket::bind("[::1]:0").await.unwrap(); + let taken = squatter.local_addr().unwrap(); + + let mut config = crate::Config::new(); + config.transports.udp = crate::config::TransportInstances::Single(crate::config::UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + ..Default::default() + }); + config.dns.enabled = true; + config.dns.bind_addr = Some("::1".to_string()); + config.dns.port = Some(taken.port()); + let mut node = make_node_with(config); + + node.start().await.unwrap(); + + assert!( + node.dns_local_addr().is_none(), + "an unbound responder must not publish an address", + ); + + node.stop().await.unwrap(); + drop(squatter); +} + /// A connection whose handshake failed is retained with BOTH Noise handles /// empty, and the stale-connection sweep depends on that: presence of the /// pending connection — not presence of a handle — is what marks a machine as