Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,

/// How this node's **application** overlay data path is realized.
///
/// Defaults to [`TransportMode::Netstack`](ts_control::TransportMode::Netstack), the userspace
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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![],
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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!(
Expand All @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
///
Expand Down
13 changes: 13 additions & 0 deletions ts_control/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,

/// WireGuard persistent-keepalive interval applied to every peer, or `None` to disable persistent
/// keepalives (`PersistentKeepalive`; Tailscale uses 25s).
///
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions ts_control/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ pub struct Node {
/// The underlay addresses this node is reachable on (`Endpoints` in Go).
pub underlay_addresses: Vec<SocketAddr>,

/// 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<String>,

/// The DERP region for this node, if known.
pub derp_region: Option<ts_derp::RegionId>,

Expand Down Expand Up @@ -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<Vec<&str>>` to owned `Vec<String>`; 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,
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
}
}
Expand Down
1 change: 1 addition & 0 deletions ts_control/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>`. Empty here so this
// exhaustive literal compiles once S4's field lands.
Expand Down
1 change: 1 addition & 0 deletions ts_control/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ mod tests {
is_wireguard_only: false,
exit_node_dns_resolvers: vec![],
peer_relay: false,
ssh_host_keys: vec![],
service_vips,
}
}
Expand Down
1 change: 1 addition & 0 deletions ts_runtime/src/derp_latency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Loading