diff --git a/docs/design/fips-ipv6-adapter.md b/docs/design/fips-ipv6-adapter.md index 383559a..06ad35a 100644 --- a/docs/design/fips-ipv6-adapter.md +++ b/docs/design/fips-ipv6-adapter.md @@ -302,6 +302,34 @@ alternative — running under a dedicated unprivileged service account with the capability granted on the binary — see [../how-to/run-as-unprivileged-user.md](../how-to/run-as-unprivileged-user.md). +### App-Owned TUN (embedded hosts) + +On platforms where FIPS is embedded rather than run as a daemon — notably +Android, where the `VpnService` owns the TUN fd and the app has no +`CAP_NET_ADMIN` — FIPS does not create `fips0` itself. Instead the embedder owns +the fd and exchanges IPv6 packet bytes with FIPS over channels. + +`Node::enable_app_owned_tun()` sets this up. It is called after `Node::new` and +before `start()` (and before the node is moved into a background task), mirroring +`control_read_handle()`, and returns two app-side channel ends: + +- **app → mesh** — the embedder pushes IPv6 packets read from its fd into + `app_outbound_tx`. These are drained by `run_rx_loop` into `handle_tun_outbound` + and routed exactly as the Reader Thread's output would be. +- **mesh → app** — inbound mesh traffic on port 256 is reconstructed and written + to the node's `tun_tx` (the same sink the Writer Thread reads); the embedder + pulls from `app_inbound_rx` and writes to its fd. + +With the channels installed, `start()` skips system-TUN creation (it gates on +`tun_tx` being unset), so FIPS does no `CAP_NET_ADMIN` operations. + +Because packets enter via `app_outbound_tx` rather than the Reader Thread, they +**bypass `handle_tun_packet`** — the `fd00::/8` destination filter, the ICMPv6 +Destination Unreachable for off-mesh dests (see [Reader Thread](#reader-thread)), +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. + ## Implementation Status | Feature | Status | diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index be9a4d5..bd577a9 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -1201,8 +1201,10 @@ impl Node { // This allows handshake messages to be sent before we start accepting packets self.initiate_peer_connections().await; - // Initialize TUN interface last, after transports and peers are ready - if self.config().tun.enabled { + // Initialize TUN interface last, after transports and peers are ready. + // Skip when the TUN is app-owned (the embedder pre-set `tun_tx` via + // `enable_app_owned_tun`) — then FIPS does no system-TUN ops. + if self.config().tun.enabled && self.tun_tx.is_none() { let address = *self.identity().address(); match TunDevice::create(&self.config().tun, address).await { Ok(device) => { diff --git a/src/node/mod.rs b/src/node/mod.rs index 3098cd9..320ecb9 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -65,7 +65,7 @@ use crate::transport::{ use crate::tree::TreeState; use crate::upper::hosts::HostMap; use crate::upper::icmp_rate_limit::IcmpRateLimiter; -use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx}; +use crate::upper::tun::{TunError, TunOutboundRx, TunOutboundTx, TunState, TunTx}; use crate::utils::index::IndexAllocator; use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr, PeerIdentity}; use rand::Rng; @@ -2856,6 +2856,37 @@ impl Node { self.tun_tx.as_ref() } + /// Set up an **app-owned TUN**: rather than FIPS creating a system TUN + /// device, the embedder (e.g. an Android `VpnService`) owns the fd and + /// exchanges IPv6 packet bytes with FIPS over the returned channels. Call + /// this after [`Node::new`] and **before** [`Self::start`] — and before + /// moving the node into a background task — exactly like + /// [`Self::control_read_handle`]. + /// + /// Returns `(app_outbound_tx, app_inbound_rx)`: + /// - push IPv6 packets read from the app's TUN fd into `app_outbound_tx` + /// (app → mesh); FIPS routes them to the destination node. + /// - pull IPv6 packets destined for the app's TUN fd from `app_inbound_rx` + /// (mesh → app) and write them to the fd (`recv_timeout` for clean stop). + /// + /// With this set, [`Self::start`] skips system-TUN creation (it gates on + /// `tun_tx` being unset). Packets pushed into `app_outbound_tx` bypass the + /// system-TUN reader's `handle_tun_packet`, so the embedder must do what that + /// path otherwise would: push only `fd::/8`-destined IPv6 packets — FIPS no + /// longer filters the destination or emits ICMPv6 unreachable for off-mesh + /// dests — and clamp TCP MSS on outbound SYNs. + pub fn enable_app_owned_tun(&mut self) -> (TunOutboundTx, std::sync::mpsc::Receiver>) { + let tun_channel_size = self.config().node.buffers.tun_channel; + // app → mesh: the app pushes; `run_rx_loop` drains `tun_outbound_rx`. + let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel(tun_channel_size); + // mesh → app: the node writes inbound packets to `tun_tx`; the app pulls. + let (tun_tx, tun_rx) = std::sync::mpsc::channel(); + self.tun_tx = Some(tun_tx); + self.tun_outbound_rx = Some(outbound_rx); + self.tun_state = TunState::Active; + (outbound_tx, tun_rx) + } + // === 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 b2f78ee..3a8e623 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -1995,3 +1995,58 @@ async fn handle_msg1_admits_existing_peer_at_cap() { "rate limiter must rebalance after the (bypass-admitted) handler returns" ); } + +/// App-owned TUN seam: `enable_app_owned_tun` wires the embedder's packet +/// channels (an Android `VpnService` owns the fd) and marks the TUN active so +/// `start()` skips system-TUN creation. +#[test] +fn app_owned_tun_seam_wires_channels() { + let mut config = crate::Config::new(); + config.tun.enabled = true; + let mut node = make_node_with(config); + + let (outbound_tx, tun_rx) = node.enable_app_owned_tun(); + + // TUN is active and the inbound (mesh→app) sender is installed, so `start()` + // will skip `TunDevice::create` (it gates on `tun_tx.is_none()`). + assert_eq!(node.tun_state(), crate::upper::tun::TunState::Active); + assert!(node.tun_tx().is_some(), "inbound sender installed"); + + // mesh → app: a packet the node delivers to its `tun_tx` reaches the app's rx. + let pkt = vec![0x60u8, 0, 0, 0, 0, 0]; + node.tun_tx().unwrap().send(pkt.clone()).unwrap(); + assert_eq!( + tun_rx + .recv_timeout(std::time::Duration::from_millis(200)) + .unwrap(), + pkt, + "the app pulls the same bytes the node wrote", + ); + + // app → mesh: the returned sender is live (its matching rx is held by the node + // and drained by `run_rx_loop` → `handle_tun_outbound`). + assert!(outbound_tx.try_send(vec![0x60]).is_ok()); +} + +/// With an app-owned TUN configured, `start()` must NOT create a system TUN +/// device: it leaves `tun_name` unset (a real device records its interface name) +/// and keeps the TUN `Active` with the app-owned channels. +#[tokio::test] +async fn start_skips_system_tun_when_app_owned() { + let mut config = crate::Config::new(); + config.tun.enabled = true; + let mut node = make_node_with(config); + + let (_outbound_tx, _tun_rx) = node.enable_app_owned_tun(); + node.start().await.unwrap(); + + // No system device was created (that path records the interface name); the + // app-owned TUN stayed active. + assert!( + node.tun_name().is_none(), + "app-owned TUN must not create a named system device", + ); + assert_eq!(node.tun_state(), crate::upper::tun::TunState::Active); + + node.stop().await.unwrap(); +}