diff --git a/src/config.rs b/src/config.rs index 3368acf..9bc6bcc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -237,6 +237,22 @@ pub struct Config { /// no-op event source (the Linux/macOS backends land in later slices). pub network_monitor: bool, + /// The fixed UDP port magicsock binds for WireGuard + disco, or `None` for an OS-chosen + /// ephemeral port (Go `tailscaled --port`; Go's `ListenPort`). Defaults to `None`. + /// + /// `None` (the default) preserves the historical behavior: the underlay socket binds `0.0.0.0:0` + /// and the OS picks an ephemeral port (Go's port `0`). `Some(p)` pins the bind to port `p` so the + /// node's UDP endpoint is stable across restarts — what an operator behind a fixed-pinhole + /// firewall needs (Go's daemon defaults this to `41641`, but the engine default stays `None` to + /// keep today's behavior). If `p` is already taken at startup the bind **falls back to an + /// ephemeral port** rather than failing bring-up (mirroring magicsock's rebind fallback): a port + /// collision must not take the node down. A later [`Device::rebind`](crate::Device::rebind) + /// re-prefers whatever port is currently bound, so a successful pin carries across rebinds. + /// + /// Governs **only** the bound port — never the bind family: the IPv4-only-by-default, + /// fail-closed underlay posture (`enable_ipv6` alone widens the family) is unchanged. + pub wireguard_listen_port: Option, + /// How this node's **application** overlay data path is realized. /// /// Defaults to [`TransportMode::Netstack`](ts_control::TransportMode::Netstack), the userspace @@ -518,6 +534,7 @@ impl From<&Config> for ts_control::Config { taildrop_dir: value.taildrop_dir.clone(), enable_ipv6: value.enable_ipv6, network_monitor: value.network_monitor, + wireguard_listen_port: value.wireguard_listen_port, transport_mode: value.transport_mode.clone(), wire_ingress: value.wire_ingress, // A fresh runtime-local flag (default `false`): the runtime flips it when @@ -555,6 +572,7 @@ impl Default for Config { persistent_keepalive_interval: Some(ts_control::DEFAULT_PERSISTENT_KEEPALIVE), enable_ipv6: false, network_monitor: false, + wireguard_listen_port: None, transport_mode: ts_control::TransportMode::default(), wire_ingress: false, advertise_services: vec![], @@ -591,6 +609,7 @@ mod tests { persistent_keepalive_interval: Some(std::time::Duration::from_secs(17)), enable_ipv6: true, network_monitor: true, + wireguard_listen_port: Some(41641), wire_ingress: true, transport_mode: ts_control::TransportMode::Tun(ts_control::TunConfig { name: Some("tailscale0".to_owned()), @@ -637,6 +656,11 @@ mod tests { control.network_monitor, "network_monitor crosses the boundary (set true)" ); + assert_eq!( + control.wireguard_listen_port, + Some(41641), + "wireguard_listen_port crosses the boundary" + ); assert!(control.wire_ingress); assert_eq!(control.advertise_services, vec!["svc:samba".to_owned()]); assert_eq!( @@ -660,6 +684,17 @@ mod tests { assert_eq!(control.transport_mode, ts_control::TransportMode::Netstack); } + /// The WireGuard listen port defaults to `None` (OS-chosen ephemeral, today's behavior) and + /// crosses the control boundary unchanged. A daemon that wants Go's `--port 41641` sets it + /// explicitly; the engine never pins a port by default. + #[test] + fn from_config_default_wireguard_listen_port_is_none() { + let cfg = Config::default(); + assert_eq!(cfg.wireguard_listen_port, None); + let control: ts_control::Config = (&cfg).into(); + assert_eq!(control.wireguard_listen_port, None); + } + #[test] fn from_config_default_has_no_exit_proxy() { let control: ts_control::Config = (&Config::default()).into(); diff --git a/src/lib.rs b/src/lib.rs index 8b0aed4..88a4fc2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1490,6 +1490,23 @@ impl Device { self.runtime.rebind().await.map_err(Into::into) } + /// Force an immediate STUN re-probe / endpoint re-derivation **without** rebinding the underlay + /// socket — the Rust analog of Go magicsock's `Conn.ReSTUN("debug")` (what `tailscale debug + /// restun` triggers). + /// + /// Unlike [`rebind`](Self::rebind), this does **not** swap the socket or disturb any learned + /// path: it keeps the existing UDP socket and its NAT mapping and only re-runs the STUN sweep + /// now (re-learning this node's reflexive/public address) instead of waiting out the periodic + /// (~23s, jittered) prober. Use it when this node's public endpoint may have changed (e.g. a NAT + /// rebinding) but the socket itself is still fine — it is strictly lighter than a rebind. + /// + /// Peers, control, the netmap, disco keys, and DERP are untouched, and there is **no control + /// round-trip**. A no-op if the underlay socket failed to bind at startup (the device is + /// DERP-only) or while no peer is configured (matching the periodic prober's gate). + pub async fn re_stun(&self) -> Result<(), Error> { + self.runtime.re_stun().await.map_err(Into::into) + } + /// The stable id of the exit node traffic is **currently** egressing through, or `None` if none /// is engaged (the equivalent of Go `tsnet`'s `Status.ExitNodeStatus.ID`). /// diff --git a/ts_control/src/config.rs b/ts_control/src/config.rs index 24822b7..a88397c 100644 --- a/ts_control/src/config.rs +++ b/ts_control/src/config.rs @@ -360,6 +360,18 @@ pub struct Config { #[serde(default)] pub network_monitor: bool, + /// The fixed UDP port magicsock binds for WireGuard + disco, or `None` for an OS-chosen + /// ephemeral port (Go `tailscaled --port` / `ListenPort`). Defaults to `None`. + /// + /// Like the other dataplane fields, this is a client-side preference not read inside + /// `ts_control`; it is carried here only to be threaded into the runtime's *initial* underlay + /// socket bind. `None` binds `0.0.0.0:0` (ephemeral, today's behavior); `Some(p)` pins port `p` + /// with an ephemeral fallback if it is already taken (a port collision never fails bring-up). + /// Governs only the bound port, never the bind family — the IPv4-only-by-default, fail-closed + /// underlay posture is unchanged. + #[serde(default)] + pub wireguard_listen_port: Option, + /// WireGuard persistent-keepalive interval applied to every peer, or `None` to disable persistent /// keepalives (`PersistentKeepalive`; Tailscale uses 25s). /// @@ -650,6 +662,7 @@ impl Default for Config { tcp_buffer_size: None, enable_ipv6: false, network_monitor: false, + wireguard_listen_port: None, persistent_keepalive_interval: default_persistent_keepalive(), transport_mode: TransportMode::default(), wire_ingress: false, diff --git a/ts_control/src/node.rs b/ts_control/src/node.rs index f6128de..3a15cf1 100644 --- a/ts_control/src/node.rs +++ b/ts_control/src/node.rs @@ -175,6 +175,13 @@ pub struct Node { /// The underlay addresses this node is reachable on (`Endpoints` in Go). pub underlay_addresses: Vec, + /// The node's advertised SSH host public keys, in known_hosts format (Go + /// `tailcfg.Hostinfo.SSHHostKeys`, surfaced by tsnet as `ipnstate.PeerStatus.SSH_HostKeys`). + /// Used by `tailscale ssh` to pin a peer's host key (TOFU). Empty when control advertised none + /// (the wire `Hostinfo.sshHostKeys` was absent), never fabricated. Projected from + /// [`ts_control_serde::HostInfo::ssh_host_keys`]. + pub ssh_host_keys: Vec, + /// The DERP region for this node, if known. pub derp_region: Option, @@ -798,6 +805,15 @@ impl From<&ts_control_serde::Node<'_>> for Node { .filter_map(Resolver::from_serde) .collect(), peer_relay: value.host_info.peer_relay, + // Project the advertised SSH host keys (Go `Hostinfo.SSHHostKeys`), mapping the + // borrowed `Option>` to owned `Vec`; absent ⇒ empty (never + // fabricated), matching how `services`/`peer_relay` above are projected from host_info. + ssh_host_keys: value + .host_info + .ssh_host_keys + .as_ref() + .map(|keys| keys.iter().map(|k| k.to_string()).collect()) + .unwrap_or_default(), service_vips, } } @@ -922,6 +938,35 @@ mod tests { assert_eq!(Node::from(&tagged).user_id, 0); } + /// The wire `Hostinfo.sshHostKeys` must be projected onto the domain `Node.ssh_host_keys` + /// (the field `tailscale ssh` reads via `StatusNode` to pin a peer's host key). Present → + /// carried verbatim; absent → empty (never fabricated). + #[test] + fn from_wire_node_carries_ssh_host_keys() { + let wire = ts_control_serde::Node { + host_info: ts_control_serde::HostInfo { + ssh_host_keys: Some(vec![ + "ssh-ed25519 AAAAC3Nz host", + "ecdsa-sha2-nistp256 AAAAE2Vj host", + ]), + ..Default::default() + }, + ..Default::default() + }; + let domain: Node = (&wire).into(); + assert_eq!( + domain.ssh_host_keys, + vec![ + "ssh-ed25519 AAAAC3Nz host".to_string(), + "ecdsa-sha2-nistp256 AAAAE2Vj host".to_string(), + ] + ); + + // Absent on the wire → empty Vec, not fabricated. + let bare = ts_control_serde::Node::default(); + assert!(Node::from(&bare).ssh_host_keys.is_empty()); + } + /// A node from an **IPv4-only** tailnet (IPv6-off control plane / Headscale) carries a /// single-element `addresses` list. This used to fail deserialization ("invalid length 1, /// expected a tuple of size 2") when `addresses` was a fixed 2-tuple; it must now parse and @@ -1102,6 +1147,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } diff --git a/ts_control/src/serve.rs b/ts_control/src/serve.rs index bfabdd0..e3c7247 100644 --- a/ts_control/src/serve.rs +++ b/ts_control/src/serve.rs @@ -706,6 +706,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), // Cross-stream coupling (S4): `Node` gains `key_signature: Vec`. Empty here so this // exhaustive literal compiles once S4's field lands. diff --git a/ts_control/src/service.rs b/ts_control/src/service.rs index 2940d45..9a6a227 100644 --- a/ts_control/src/service.rs +++ b/ts_control/src/service.rs @@ -183,6 +183,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips, } } diff --git a/ts_runtime/src/derp_latency.rs b/ts_runtime/src/derp_latency.rs index cbfb676..5b299e8 100644 --- a/ts_runtime/src/derp_latency.rs +++ b/ts_runtime/src/derp_latency.rs @@ -137,6 +137,7 @@ mod tests { peerapi_port: None, taildrop_dir: None, enable_ipv6: false, + wireguard_listen_port: None, network_monitor: false, persistent_keepalive_interval: None, ingress_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/ts_runtime/src/direct.rs b/ts_runtime/src/direct.rs index 6160c6a..bcac577 100644 --- a/ts_runtime/src/direct.rs +++ b/ts_runtime/src/direct.rs @@ -88,31 +88,65 @@ pub struct EndpointAdvertisement { pub endpoints: Arc>, } -/// The IPv4 bind address for the direct underlay socket. +/// The IPv4 bind address for the direct underlay socket (unit tests). /// -/// IPv4-only and ephemeral-port: per the anti-leak rules this socket is the only egress path -/// for the direct underlay, and IPv6 is disabled in our default deployment. This is the historical -/// (and `enable_ipv6 == false`) bind — byte-for-byte the original behavior. +/// IPv4-only and ephemeral-port: per the anti-leak rules this socket is the only egress path for +/// the direct underlay, and IPv6 is disabled in our default deployment. The production bind no +/// longer parses this string (it constructs the address from +/// [`Ipv4Addr::UNSPECIFIED`](core::net::Ipv4Addr) and the resolved port in [`bind_underlay_addr`]); +/// the constant is retained as the canonical `0.0.0.0:0` literal the socket tests bind directly. +#[cfg(test)] const BIND_ADDR: &str = "0.0.0.0:0"; -/// The dual-stack bind address used only when `Env::enable_ipv6` is `true`. +/// Bind a [`MagicSock`] to the given UNSPECIFIED-address family at `listen_port`, falling back to an +/// OS-chosen ephemeral port (`:0`) if `listen_port` is non-zero and already taken. /// -/// Binding `[::]:0` yields one socket that serves both native IPv6 and IPv4-mapped traffic when -/// the kernel's `IPV6_V6ONLY` is off (the Linux default on a typical cloud VPS, where -/// `/proc/sys/net/ipv6/bindv6only` is `0`). See [`bind_underlay_addr`] for the inert-fallback -/// posture when the host has IPv6 disabled at the kernel. -const BIND_ADDR_V6: &str = "[::]:0"; +/// The single port-collision fallback the initial bind shares with [`rebind_socket`]'s +/// `Err(_) if prefer_port != 0 => bind(0)`: a pinned [`Config::wireguard_listen_port`] that happens +/// to be taken must not fail bring-up. `listen_port == 0` (ephemeral) needs no fallback — there is +/// nothing more permissive to retry — so its error propagates unchanged. `our_disco` is cloned per +/// attempt because [`MagicSock::bind`] consumes it (it is no longer `Copy`); `our_node_key` is a +/// `Copy` public key. +async fn bind_unspecified_with_fallback( + ip: core::net::IpAddr, + listen_port: u16, + our_disco: &ts_keys::DiscoPrivateKey, + our_node_key: NodePublicKey, +) -> Result { + let pinned = SocketAddr::new(ip, listen_port); + match MagicSock::bind(pinned, our_disco.clone(), our_node_key).await { + Ok(sock) => Ok(sock), + // Pinned port taken (or otherwise unbindable): fall back to an ephemeral port so a port + // collision never takes the node down. Only when a port was actually pinned. + Err(_) if listen_port != 0 => { + tracing::warn!( + %pinned, + "underlay bind on pinned port failed; falling back to an ephemeral port", + ); + MagicSock::bind(SocketAddr::new(ip, 0), our_disco.clone(), our_node_key).await + } + Err(e) => Err(e), + } +} /// Choose the underlay UDP socket and the address it bound to, honoring the (default-off) -/// `enable_ipv6` overlay gate. +/// `enable_ipv6` overlay gate and the (default-ephemeral) `listen_port` pin. +/// +/// `listen_port` is [`Config::wireguard_listen_port`](ts_control::Config::wireguard_listen_port) +/// resolved to a `u16` (`0` = OS-chosen ephemeral, today's behavior; non-zero = pin that port with +/// an ephemeral fallback if it is taken — see [`bind_unspecified_with_fallback`]). It governs only +/// the bound port; the bind *family* still follows `enable_ipv6`: /// -/// - `enable_ipv6 == false` (default): bind exactly [`BIND_ADDR`] (`0.0.0.0:0`) — byte-for-byte the -/// historical IPv4-only path, no new syscalls. This upholds the sacred IPv4-only invariant of the +/// - `enable_ipv6 == false` (default): bind `0.0.0.0:listen_port` (`0.0.0.0:0` = byte-for-byte the +/// historical IPv4-only ephemeral path). This upholds the sacred IPv4-only invariant of the /// privacy-proxy deployment. -/// - `enable_ipv6 == true`: attempt a dual-stack bind on [`BIND_ADDR_V6`] (`[::]:0`) so a single -/// socket serves both native v6 and v4-mapped traffic. **Fail inert, never panic**: if the v6 -/// bind fails (e.g. a host with `net.ipv6.conf.all.disable_ipv6=1`), warn and fall back to the -/// IPv4 bind so the node still comes up — protective if the gate is mis-flagged on a hardened box. +/// - `enable_ipv6 == true`: attempt a dual-stack bind on `[::]:listen_port` so a single socket +/// serves both native v6 and v4-mapped traffic. **Fail inert, never panic**: if the v6 bind fails +/// (e.g. a host with `net.ipv6.conf.all.disable_ipv6=1`), warn and fall back to the IPv4 bind so +/// the node still comes up — protective if the gate is mis-flagged on a hardened box. +/// +/// A successful pinned port carries across a later [`MagicSock::rebind`], which re-prefers the +/// socket's current local port. /// /// NOTE (dep gap reported to the architect): [`MagicSock::bind`] takes only a [`SocketAddr`] and /// constructs the `tokio::net::UdpSocket` itself, so this site cannot set `IPV6_V6ONLY` explicitly @@ -123,32 +157,49 @@ const BIND_ADDR_V6: &str = "[::]:0"; /// must expose a bind that accepts a pre-configured socket. async fn bind_underlay_addr( enable_ipv6: bool, + listen_port: u16, our_disco: ts_keys::DiscoPrivateKey, our_node_key: NodePublicKey, ) -> Result { - // IPv4-only default: the historical path, unchanged. + use core::net::{Ipv4Addr, Ipv6Addr}; + + // IPv4-only default: the historical family, now honoring the pinned port (`0` = ephemeral, i.e. + // byte-for-byte the original `0.0.0.0:0`). if !enable_ipv6 { - let v4: SocketAddr = BIND_ADDR.parse().expect("valid bind address"); - return MagicSock::bind(v4, our_disco, our_node_key).await; + return bind_unspecified_with_fallback( + Ipv4Addr::UNSPECIFIED.into(), + listen_port, + &our_disco, + our_node_key, + ) + .await; } - // Overlay IPv6 enabled: try the dual-stack bind first. - let v6: SocketAddr = BIND_ADDR_V6.parse().expect("valid bind address"); - // Clone the disco key for the v6 attempt: `MagicSock::bind` consumes it (it is no longer - // `Copy`), and the IPv4 fallback below needs its own copy. `our_node_key` is a public key - // (still `Copy`), so it needs no clone. - match MagicSock::bind(v6, our_disco.clone(), our_node_key).await { + // Overlay IPv6 enabled: try the dual-stack bind (same port pin + ephemeral fallback) first. + match bind_unspecified_with_fallback( + Ipv6Addr::UNSPECIFIED.into(), + listen_port, + &our_disco, + our_node_key, + ) + .await + { Ok(sock) => Ok(sock), Err(e) => { // Inert fallback: the host likely has IPv6 disabled at the kernel. Come up IPv4-only - // rather than crash — protective on a hardened proxy box even if the gate is set. + // (still honoring the pinned port) rather than crash — protective on a hardened proxy + // box even if the gate is set. tracing::warn!( error = %e, - %v6, "dual-stack underlay bind failed (host IPv6 disabled?); falling back to IPv4-only", ); - let v4: SocketAddr = BIND_ADDR.parse().expect("valid bind address"); - MagicSock::bind(v4, our_disco, our_node_key).await + bind_unspecified_with_fallback( + Ipv4Addr::UNSPECIFIED.into(), + listen_port, + &our_disco, + our_node_key, + ) + .await } } } @@ -306,16 +357,49 @@ impl DirectManager { // while there are no peers — Go's len(peerSet)==0 stop). Best-effort throughout: a stale // derp/multiderp or empty server list just skips this round; pong-harvest from the // re-pings above still re-learns reflexives. + self.stun_sweep_once(sock).await; + + Ok(()) + } + + /// Force an immediate STUN/endpoint re-probe **without** rebinding the underlay socket — the + /// engine half of `Device::re_stun` (Go magicsock's `Conn.ReSTUN`). This is the STUN sweep of + /// [`rebind_and_reprobe`](Self::rebind_and_reprobe) (step 3) on its own: it does NOT swap the + /// socket and does NOT re-ping peers, so the existing socket, its NAT mapping, and every learned + /// path are preserved — it only re-learns our reflexive (public) address right now instead of + /// waiting out the jittered ~23s periodic [`run_stun_prober`] timer. + /// + /// Lighter than [`rebind`](Self::rebind)/[`rebind_and_reprobe`](Self::rebind_and_reprobe): use it + /// when our public endpoint may have changed (e.g. a NAT rebinding) but the socket itself is + /// fine. A no-op (`Ok`) when the underlay bind failed at startup (DERP-only inert mode — no + /// socket to probe from). Best-effort and gated exactly like the periodic prober (skipped while + /// there are no peers — Go's `len(peerSet)==0` stop): a stale derp/multiderp or empty server list + /// simply skips this round. + #[message] + pub async fn re_stun(&self) -> Result<(), ts_magicsock::Error> { + let Some(sock) = self.sock.as_ref() else { + // Inert / DERP-only: no socket to STUN from. + return Ok(()); + }; + self.stun_sweep_once(sock).await; + Ok(()) + } + + /// One immediate STUN sweep from the bound socket, gated like the periodic prober (skip while + /// there are no peers — Go's `len(peerSet)==0` stop). Best-effort: a stale/unavailable multiderp + /// or an empty v4-STUN-server list just skips this round (pong-harvest still re-learns + /// reflexives). Shared by [`rebind_and_reprobe`](Self::rebind_and_reprobe) (after the rebind + + /// re-ping) and [`re_stun`](Self::re_stun) (on its own, no rebind), so the gate + server fetch + + /// per-server fan-out live in one place. + async fn stun_sweep_once(&self, sock: &MagicSock) { if stun_probe_should_run(&self.peer_db) { match self.multiderp.ask(multiderp::StunServersV4).await { Ok((servers,)) => probe_stun_servers_once(sock, &servers).await, Err(e) => { - tracing::trace!(error = %e, "rebind-and-reprobe: querying stun servers"); + tracing::trace!(error = %e, "stun sweep: querying stun servers"); } } } - - Ok(()) } } @@ -723,6 +807,10 @@ impl kameo::Actor for DirectManager { // [`bind_underlay_addr`]. let sock = match bind_underlay_addr( env.enable_ipv6, + // The pinned WireGuard/disco port (`Config::wireguard_listen_port`), or `0` for an + // OS-chosen ephemeral port (today's default). A pinned-but-taken port falls back to + // ephemeral inside `bind_underlay_addr` so a collision never fails bring-up. + env.wireguard_listen_port.unwrap_or(0), // `.clone()`: the disco private key is no longer `Copy` and `env` is shared (`Arc`), // so clone it out for the bind. `node_keys.public` is a `Copy` public key. env.keys.disco_keys.private.clone(), @@ -877,6 +965,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } @@ -1014,6 +1103,7 @@ mod tests { async fn bind_underlay_addr_v4_default_is_unchanged() { let sock = bind_underlay_addr( false, + 0, DiscoPrivateKey::random(), NodePrivateKey::random().public_key(), ) @@ -1032,6 +1122,64 @@ mod tests { ); } + /// A pinned `wireguard_listen_port` (Go `--port`) binds exactly that UDP port when it is free — + /// the stable-endpoint behavior an operator behind a fixed-pinhole firewall needs. Uses a port + /// the OS just handed out (then released) to avoid colliding with anything already bound. + #[tokio::test] + async fn bind_underlay_addr_pins_requested_port_when_free() { + // Grab an OS-assigned port, then release it so we can pin it deterministically. + let probe = tokio::net::UdpSocket::bind("0.0.0.0:0").await.unwrap(); + let want = probe.local_addr().unwrap().port(); + drop(probe); + + let sock = bind_underlay_addr( + false, + want, + DiscoPrivateKey::random(), + NodePrivateKey::random().public_key(), + ) + .await + .expect("pinned-port underlay bind must succeed"); + + let local = sock.local_addr().expect("a bound socket has a local addr"); + assert!(local.is_ipv4(), "still the v4 family, got {local}"); + assert_eq!( + local.port(), + want, + "a free pinned port must be bound exactly (got {local})" + ); + } + + /// A pinned port that is already taken must NOT fail bring-up: the bind falls back to an + /// OS-chosen ephemeral port (mirroring `rebind_socket`'s `Err(_) if prefer_port != 0 => bind(0)` + /// fallback), so a port collision can never take the node down. The bound port ends up different + /// from the (occupied) pinned one. + #[tokio::test] + async fn bind_underlay_addr_falls_back_to_ephemeral_when_port_taken() { + // Occupy a port for the whole test so the pinned bind below must collide. + let occupier = tokio::net::UdpSocket::bind("0.0.0.0:0").await.unwrap(); + let taken = occupier.local_addr().unwrap().port(); + + let sock = bind_underlay_addr( + false, + taken, + DiscoPrivateKey::random(), + NodePrivateKey::random().public_key(), + ) + .await + .expect("a taken pinned port must fall back to ephemeral, never error"); + + let local = sock.local_addr().expect("a bound socket has a local addr"); + assert!(local.is_ipv4(), "still the v4 family, got {local}"); + assert_ne!( + local.port(), + taken, + "the occupied port must not be bound; an ephemeral port is used instead" + ); + assert_ne!(local.port(), 0, "a real ephemeral port must be assigned"); + drop(occupier); + } + /// With `enable_ipv6 == true` a dual-stack bind on `[::]:0` is attempted. On a normal dev host /// that yields a v6-family socket; if this environment cannot bind v6 at all, the documented /// inert fallback returns a v4 socket instead (never a panic, never an error). Either outcome is @@ -1042,6 +1190,7 @@ mod tests { async fn bind_underlay_addr_v6_attempts_dual_stack_or_falls_back() { let sock = bind_underlay_addr( true, + 0, DiscoPrivateKey::random(), NodePrivateKey::random().public_key(), ) @@ -1116,6 +1265,45 @@ mod tests { ); } + /// `re_stun` (Go `Conn.ReSTUN`) is the STUN sweep WITHOUT a rebind: its body is exactly the + /// shared [`DirectManager::stun_sweep_once`] gate — skip while there are no peers (Go's + /// `len(peerSet)==0` stop), otherwise fetch the v4 STUN servers and probe each. This pins the two + /// gate decisions `re_stun` makes (the peer-presence gate it shares with the periodic prober, and + /// the empty-server-list no-op), independent of the actor machinery — the same way the periodic + /// prober's per-tick fan-out is pinned by [`probe_stun_servers_once_*`]. The inert (no-socket, + /// DERP-only) path is the `self.sock.is_none()` early-return, structurally identical to + /// [`DirectManager::rebind`]'s inert no-op. + #[tokio::test] + async fn re_stun_sweep_gate_matches_periodic_prober() { + // No peers (and no netmap) → the sweep is gated closed, so re_stun probes nothing. + let no_peers: Arc>>> = Default::default(); + assert!( + !stun_probe_should_run(&no_peers), + "re_stun must skip the sweep with no peers, like the periodic prober" + ); + + // With a peer present the gate opens; an empty derp v4-STUN list is then a clean no-op + // (probing must never require a STUN server — pong-harvest backstops it). + let disco = DiscoPrivateKey::random().public_key(); + let node_key = NodePrivateKey::random().public_key(); + let with_peer = db_with(node_with_keys(disco, node_key, "n1")); + assert!( + stun_probe_should_run(&with_peer), + "re_stun sweeps once a peer is present" + ); + let sock = Arc::new( + MagicSock::bind( + BIND_ADDR.parse().unwrap(), + DiscoPrivateKey::random(), + NodePrivateKey::random().public_key(), + ) + .await + .unwrap(), + ); + // Empty server list (what an unavailable/stale derp map yields) → no sends, returns promptly. + probe_stun_servers_once(&sock, &[]).await; + } + /// The periodic STUN delay is a uniform random value in `[20s, 26s)`, matching Go magicsock's /// `RandomDurationBetween(20s, 26s)` re-arm — never a fixed 30s beat, and always strictly under /// the ~30s UDP-NAT-timeout ceiling. Sampling many draws pins both the bounds and that the value diff --git a/ts_runtime/src/env.rs b/ts_runtime/src/env.rs index 8f965cf..fd214d8 100644 --- a/ts_runtime/src/env.rs +++ b/ts_runtime/src/env.rs @@ -122,6 +122,15 @@ pub struct ForwarderConfig { /// regardless to uphold the real-origin-IP isolation invariant. pub enable_ipv6: bool, + /// The fixed UDP port to bind the underlay socket on (WireGuard + disco), or `None` for an + /// OS-chosen ephemeral port. Defaults to `None`. + /// + /// See [`Config::wireguard_listen_port`](ts_control::Config::wireguard_listen_port). Read once at + /// `Runtime::spawn` to choose the *initial* underlay bind port: `Some(p)` pins port `p` (with an + /// ephemeral fallback if taken), `None` binds `0`. Governs only the port — never the bind family + /// (the IPv4-only-by-default posture is unchanged). + pub wireguard_listen_port: Option, + /// Whether to run the internal OS network-link monitor (`NetmonSupervisor`). Defaults to /// `false`. /// @@ -169,6 +178,7 @@ impl ForwarderConfig { peerapi_port: config.peerapi_port, taildrop_dir: config.taildrop_dir.clone(), enable_ipv6: config.enable_ipv6, + wireguard_listen_port: config.wireguard_listen_port, network_monitor: config.network_monitor, persistent_keepalive_interval: config.persistent_keepalive_interval, ingress_active: config.ingress_active.clone(), @@ -293,6 +303,13 @@ pub struct Env { /// netstack address assignment, and MagicDNS; never by the forwarder egress path. pub enable_ipv6: bool, + /// The fixed UDP port to bind the underlay socket on, or `None` for an OS-chosen ephemeral port + /// (default `None`). + /// + /// See [`ForwarderConfig::wireguard_listen_port`]. Read once by the direct manager at startup to + /// choose the initial underlay bind port; never re-read at runtime. + pub wireguard_listen_port: Option, + /// Whether the internal OS network-link monitor (`NetmonSupervisor`) is enabled (default /// `false`). /// @@ -399,6 +416,7 @@ impl Env { peerapi_port, taildrop_dir, enable_ipv6, + wireguard_listen_port, network_monitor, persistent_keepalive_interval, ingress_active, @@ -435,6 +453,7 @@ impl Env { peerapi_port, taildrop_store, enable_ipv6, + wireguard_listen_port, network_monitor, persistent_keepalive_interval, ingress_active, @@ -507,6 +526,7 @@ mod tests { peerapi_port: None, taildrop_dir: None, enable_ipv6: false, + wireguard_listen_port: None, network_monitor: false, persistent_keepalive_interval: None, ingress_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -523,6 +543,31 @@ mod tests { (env, cells.accept_routes) } + /// The WireGuard listen port threads from `ForwarderConfig` onto the `Env` the direct manager + /// reads at its initial bind: `None` (the default) stays `None` (ephemeral bind), and a pinned + /// `Some(p)` is carried verbatim. `Env::new_with_runtime_txs` spawns the `MessageBus` actor, + /// which needs a Tokio reactor. + #[tokio::test] + async fn wireguard_listen_port_threads_to_env() { + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + + // Default: None (ephemeral). + let env_none = Env::new( + ts_keys::NodeState::generate(), + shutdown_rx.clone(), + cfg(false), + ); + assert_eq!(env_none.wireguard_listen_port, None); + + // Pinned: Some(p) carried verbatim onto the Env. + let pinned = ForwarderConfig { + wireguard_listen_port: Some(41641), + ..cfg(false) + }; + let env_pinned = Env::new(ts_keys::NodeState::generate(), shutdown_rx, pinned); + assert_eq!(env_pinned.wireguard_listen_port, Some(41641)); + } + /// `Env::accept_routes()` reflects the `ForwarderConfig` seed. // `Env::new_with_runtime_txs` spawns the `MessageBus` actor, which needs a Tokio reactor. #[tokio::test] diff --git a/ts_runtime/src/forwarder_actor.rs b/ts_runtime/src/forwarder_actor.rs index b0104b4..edca60c 100644 --- a/ts_runtime/src/forwarder_actor.rs +++ b/ts_runtime/src/forwarder_actor.rs @@ -300,6 +300,7 @@ mod tests { peerapi_port: None, taildrop_dir: None, enable_ipv6: false, + wireguard_listen_port: None, network_monitor: false, persistent_keepalive_interval: None, ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/ts_runtime/src/ipn_bus.rs b/ts_runtime/src/ipn_bus.rs index 287d54f..fb5a231 100644 --- a/ts_runtime/src/ipn_bus.rs +++ b/ts_runtime/src/ipn_bus.rs @@ -386,6 +386,7 @@ mod tests { is_exit_node: false, cur_addr: None, relay: None, + ssh_host_keys: Vec::new(), } } diff --git a/ts_runtime/src/lib.rs b/ts_runtime/src/lib.rs index e53f29d..841a51f 100644 --- a/ts_runtime/src/lib.rs +++ b/ts_runtime/src/lib.rs @@ -578,6 +578,16 @@ impl Runtime { self.direct.ask(direct::Rebind).await.map_err(Error::from) } + /// Force an immediate STUN / endpoint re-probe **without** rebinding the underlay socket — + /// Go magicsock's `Conn.ReSTUN`. Asks the `DirectManager` to run one STUN sweep now (re-learn + /// our reflexive/public address) while leaving the socket, its NAT mapping, learned paths, peers, + /// control, and DERP untouched. Lighter than [`rebind`](Self::rebind): no socket swap, no + /// re-ping. A no-op when the underlay is inert (bind failed at startup, DERP-only). No control + /// round-trip. + pub async fn re_stun(&self) -> Result<(), Error> { + self.direct.ask(direct::ReStun).await.map_err(Error::from) + } + /// A snapshot of the local netmap: this node plus every known peer. /// /// Combines the self node held by the control runner with the peer set held by the peer diff --git a/ts_runtime/src/magic_dns.rs b/ts_runtime/src/magic_dns.rs index 4dc1525..6fd41d4 100644 --- a/ts_runtime/src/magic_dns.rs +++ b/ts_runtime/src/magic_dns.rs @@ -1127,6 +1127,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } diff --git a/ts_runtime/src/netmon.rs b/ts_runtime/src/netmon.rs index 37ed0d5..5328b23 100644 --- a/ts_runtime/src/netmon.rs +++ b/ts_runtime/src/netmon.rs @@ -257,6 +257,7 @@ mod tests { peerapi_port: None, taildrop_dir: None, enable_ipv6: false, + wireguard_listen_port: None, network_monitor: true, persistent_keepalive_interval: None, ingress_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/ts_runtime/src/netstack_actor.rs b/ts_runtime/src/netstack_actor.rs index 5a7951e..5921568 100644 --- a/ts_runtime/src/netstack_actor.rs +++ b/ts_runtime/src/netstack_actor.rs @@ -221,6 +221,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips, } } diff --git a/ts_runtime/src/peer_tracker/mod.rs b/ts_runtime/src/peer_tracker/mod.rs index d3a1e80..e7f22fc 100644 --- a/ts_runtime/src/peer_tracker/mod.rs +++ b/ts_runtime/src/peer_tracker/mod.rs @@ -1101,6 +1101,7 @@ mod tka_tests { peerapi_port: None, taildrop_dir: None, enable_ipv6: false, + wireguard_listen_port: None, network_monitor: false, persistent_keepalive_interval: None, ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1138,6 +1139,7 @@ mod tka_tests { is_wireguard_only: false, exit_node_dns_resolvers: Vec::new(), peer_relay: false, + ssh_host_keys: Vec::new(), service_vips: Default::default(), } } diff --git a/ts_runtime/src/peer_tracker/peer_db.rs b/ts_runtime/src/peer_tracker/peer_db.rs index 1b9cae4..445d9f5 100644 --- a/ts_runtime/src/peer_tracker/peer_db.rs +++ b/ts_runtime/src/peer_tracker/peer_db.rs @@ -572,6 +572,7 @@ mod test { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } @@ -985,6 +986,7 @@ mod test { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } }) diff --git a/ts_runtime/src/peerapi.rs b/ts_runtime/src/peerapi.rs index 7d8a034..276a531 100644 --- a/ts_runtime/src/peerapi.rs +++ b/ts_runtime/src/peerapi.rs @@ -803,6 +803,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } diff --git a/ts_runtime/src/route_updater.rs b/ts_runtime/src/route_updater.rs index 52800e3..9b45979 100644 --- a/ts_runtime/src/route_updater.rs +++ b/ts_runtime/src/route_updater.rs @@ -543,6 +543,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } diff --git a/ts_runtime/src/src_filter.rs b/ts_runtime/src/src_filter.rs index 747864e..91e8dee 100644 --- a/ts_runtime/src/src_filter.rs +++ b/ts_runtime/src/src_filter.rs @@ -111,6 +111,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } diff --git a/ts_runtime/src/status.rs b/ts_runtime/src/status.rs index 5e57f9e..0c51374 100644 --- a/ts_runtime/src/status.rs +++ b/ts_runtime/src/status.rs @@ -91,6 +91,11 @@ pub struct StatusNode { /// and the peer's home DERP region is known; `None` when a direct path is confirmed, or the /// region code is unknown. Carries the region **code**, not its numeric id. pub relay: Option, + /// The node's advertised SSH host public keys in known_hosts format (Go + /// `ipnstate.PeerStatus.SSH_HostKeys`), used by `tailscale ssh` to pin the peer's host key. + /// Mirrors the domain [`Node::ssh_host_keys`](ts_control::Node::ssh_host_keys); empty when + /// control advertised none (never fabricated). + pub ssh_host_keys: Vec, } impl StatusNode { @@ -117,6 +122,7 @@ impl StatusNode { // self node and whois lookups (which also use `from_node`) correctly keep `None`. cur_addr: None, relay: None, + ssh_host_keys: node.ssh_host_keys.clone(), } } } @@ -313,6 +319,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), } } @@ -351,6 +358,21 @@ mod tests { assert_eq!(StatusNode::from_node(&offline).online, Some(false)); } + #[test] + fn status_node_carries_ssh_host_keys() { + // Absent on the domain node → empty on StatusNode (never fabricated). + let bare = node("n1", "host", Some("ts.net"), "100.64.0.1"); + assert!(StatusNode::from_node(&bare).ssh_host_keys.is_empty()); + + // Present → mirrored verbatim (the keys `tailscale ssh` pins). + let mut with_keys = node("n2", "host", Some("ts.net"), "100.64.0.2"); + with_keys.ssh_host_keys = vec!["ssh-ed25519 AAAAC3Nz host".to_string()]; + assert_eq!( + StatusNode::from_node(&with_keys).ssh_host_keys, + vec!["ssh-ed25519 AAAAC3Nz host".to_string()] + ); + } + #[test] fn status_node_detects_exit_node() { let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1"); diff --git a/ts_runtime/src/tun_actor.rs b/ts_runtime/src/tun_actor.rs index fb6b8bd..9f543cf 100644 --- a/ts_runtime/src/tun_actor.rs +++ b/ts_runtime/src/tun_actor.rs @@ -1003,6 +1003,7 @@ mod tests { peerapi_port: None, taildrop_dir: None, enable_ipv6: false, + wireguard_listen_port: None, network_monitor: false, persistent_keepalive_interval: None, ingress_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1041,6 +1042,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), online: None, last_seen: None, @@ -1119,6 +1121,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), online: None, last_seen: None, @@ -1176,6 +1179,7 @@ mod tests { is_wireguard_only: false, exit_node_dns_resolvers: vec![], peer_relay: false, + ssh_host_keys: vec![], service_vips: Default::default(), online: None, last_seen: None,