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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ Record breaking or significant changes here. All dates are UTC.
* **magicsock:** a socket bound to `0.0.0.0` advertises the host's interface addresses, selected as Go does, instead of `0.0.0.0` itself; no unspecified, multicast or broadcast endpoint, nor loopback unless the socket is bound to loopback, reaches control or a CallMeMaybe.
* **magicsock:** reflexive addresses record whether STUN or a peer's pong reported them and stop being advertised once a STUN sweep finds them unseen for 5 min 10 s (Go's endpoint tracker lifetime and trigger; an idle node keeps advertising its last set). When the set is full, a new address replaces the stalest entry that only peers reported, and a peer's observation never displaces a live STUN result. A pong-reported address is kept only if a peer would accept it as a candidate and it is outside `100.64.0.0/10`, so router-side hairpin addresses are no longer advertised or counted as symmetric-NAT evidence.
* **magicsock:** learn a peer's private, carrier-grade NAT or hairpin address from its authenticated disco pings and `CallMeMaybe`, as Go does, and accept WireGuard data from that address (this tree gates data on a learned address; Go accepts data from any source and finds the peer cryptographically); a peer reachable only through the router's hairpin no longer stays on DERP. Relay addresses keep the stricter filter.

### Features

* **portmapper:** add `ts_portmapper`, a NAT-PMP and PCP client modelled on Go's `net/portmapper`, which only ever sends to the LAN gateway's port 5351.
* **runtime:** map the underlay UDP port on the LAN gateway with NAT-PMP or PCP and advertise the mapping to peers, as Go does; `Config::disable_port_mapper` or `TS_DISABLE_PORTMAPPER` turns it off.

### Performance

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ ts_http_util = { path = "ts_http_util", package = "geiserx_ts_http_util", versio
ts_portmapper = { path = "ts_portmapper", package = "geiserx_ts_portmapper", version = "0.57.8" } # x-release-please-version
ts_tka = { path = "ts_tka", package = "geiserx_ts_tka", version = "0.57.8" } # x-release-please-version
ts_tls_util = { path = "ts_tls_util", package = "geiserx_ts_tls_util", version = "0.57.8" } # x-release-please-version
ts_runtime = { path = "ts_runtime", package = "geiserx_ts_runtime", version = "0.57.8" } # x-release-please-version
ts_runtime = { path = "ts_runtime", package = "geiserx_ts_runtime", version = "0.57.8", default-features = false } # x-release-please-version
ts_test_util = { path = "ts_test_util" }
ts_time = { path = "ts_time", package = "geiserx_ts_time", version = "0.57.8" } # x-release-please-version
ts_transport = { path = "ts_transport", package = "geiserx_ts_transport", version = "0.57.8" } # x-release-please-version
Expand Down Expand Up @@ -350,7 +350,12 @@ hyper-util = { workspace = true, features = ["tokio"] }
workspace = true

[features]
default = ["portmapper"]
ring-aead = ["ts_runtime/ring-aead"]
# On by default (Go parity): the NAT port mapper (NAT-PMP/PCP) for direct paths through hard NATs.
# `Config::disable_port_mapper` and `TS_DISABLE_PORTMAPPER` turn it off at runtime; without this
# feature it is not compiled at all.
portmapper = ["ts_runtime/portmapper"]
# Enable the `tsnet` module: a Go-idiomatic `tsnet.Server`-shaped facade over `Device`/`Config`
# (settable fields → `Config`, lazy start, `Listen`/`Dial`/`Up`/`Close`/`ListenFunnel`/`ListenService`).
# A thin ergonomics layer only — no new engine, no new crate. Reuses the crate's existing
Expand Down
2 changes: 2 additions & 0 deletions checks/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod no_std_honest;
mod parity_audit;
mod porting_ledger;
mod portmapper_lan_only;
mod portmapper_test_isolation;
mod restock_beads;
mod ssh_isolation;

Expand Down Expand Up @@ -40,6 +41,7 @@ pub const CHECK_FNS: &[(&str, CheckFn)] = &[
("funnel_fail_closed", funnel_fail_closed::run),
("ssh_isolation", ssh_isolation::run),
("portmapper_lan_only", portmapper_lan_only::run),
("portmapper_test_isolation", portmapper_test_isolation::run),
("porting_ledger", porting_ledger::run),
("parity_audit", parity_audit::run),
("restock_beads", restock_beads::run),
Expand Down
145 changes: 145 additions & 0 deletions checks/src/portmapper_test_isolation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! Test isolation for the NAT port mapper: no test may ask the real LAN gateway for a mapping.
//!
//! WHY: the port mapper is on by default (Go parity), so a test that builds a real device or runtime
//! would look up the host's default route and send NAT-PMP/PCP to the real router, creating real
//! mappings. The runtime's own unit tests are guarded in code: every `ForwarderConfig` names its
//! `port_mapper` field (the shared test configurations set `None`), and in that crate's test build
//! the production client's gateway lookup is a stub that panics (`ts_runtime/src/portmap.rs`,
//! `shared_test_config_never_builds_a_client`). This check guards what code cannot:
//!
//! 1. **Integration tests that build devices** (`tests/*.rs` calling `Device::new` or
//! `Server::new`) must turn the mapper off with `TS_DISABLE_PORTMAPPER` (the env knob covers
//! every device the process builds, the facade server included).
//! 2. **Runtime sources** may enable the mapper (`port_mapper: Some(`) only in the production
//! conversion (`env.rs`) and the driver's own tests (`portmap.rs`), where a harness is injected
//! or the stub lookup is in place.
//! 3. **The stub stays**: `portmap.rs` keeps the `cfg(test)` lookup stub.
//!
//! Like any text scan it is a tripwire, not a proof.

use crate::{Args, BoxResult};

/// What a device-building integration test must contain.
const ENV_KNOB: &str = "TS_DISABLE_PORTMAPPER";
/// Calls that build a device.
const DEVICE_BUILDERS: &[&str] = &["Device::new(", "Server::new("];
/// Runtime files allowed to enable the mapper.
const ENABLE_ALLOWED: &[&str] = &["env.rs", "portmap.rs"];

pub fn run(_args: &Args) -> BoxResult<()> {
let mut problems = Vec::new();

for entry in globwalk::GlobWalkerBuilder::from_patterns("tests", &["*.rs"]).build()? {
let entry = entry?;
let contents = std::fs::read_to_string(entry.path())?;
problems.extend(integration_test_problems(
&entry.path().display().to_string(),
&contents,
));
}

for entry in
globwalk::GlobWalkerBuilder::from_patterns("ts_runtime/src", &["**/*.rs"]).build()?
{
let entry = entry?;
let path = entry.path();
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let contents = std::fs::read_to_string(path)?;
problems.extend(runtime_problems(&name, &contents));
}

let portmap = std::fs::read_to_string("ts_runtime/src/portmap.rs")?;
problems.extend(stub_problems(&portmap));

if problems.is_empty() {
return Ok(());
}
eprintln!("a test could reach the real LAN gateway through the port mapper:");
for p in &problems {
eprintln!(" {p}");
}
Err("port mapper test isolation violated".into())
}

/// An integration test file that builds a device must set the env knob.
fn integration_test_problems(path: &str, contents: &str) -> Vec<String> {
let builds = DEVICE_BUILDERS.iter().any(|b| contents.contains(b));
if builds && !contents.contains(ENV_KNOB) {
vec![format!(
"{path}: builds a device but never sets {ENV_KNOB}; set it (and `disable_port_mapper`) \
before the first device"
)]
} else {
Vec::new()
}
}

/// A runtime source file other than the allowed ones must not enable the mapper.
fn runtime_problems(name: &str, contents: &str) -> Vec<String> {
if ENABLE_ALLOWED.contains(&name) {
return Vec::new();
}
contents
.lines()
.enumerate()
.filter(|(_, l)| l.replace(' ', "").contains("port_mapper:Some("))
.map(|(i, _)| {
format!(
"ts_runtime/src/{name}:{}: enables the port mapper; tests must use `port_mapper: \
None` or inject a harness in portmap.rs",
i + 1
)
})
.collect()
}

/// The `cfg(test)` lookup stub must stay in place.
fn stub_problems(portmap: &str) -> Vec<String> {
if portmap.contains("cfg.lookup = Arc::new(tests::NeverLookup)") && portmap.contains("panic!(")
{
Vec::new()
} else {
vec![
"ts_runtime/src/portmap.rs: the cfg(test) gateway-lookup stub (NeverLookup) is gone"
.to_owned(),
]
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn device_builders_need_the_env_knob() {
let bad = "async fn t() { Device::new(&Config::default(), None).await; }";
assert_eq!(integration_test_problems("tests/x.rs", bad).len(), 1);
let bad = "let mut srv = Server::new();";
assert_eq!(integration_test_problems("tests/x.rs", bad).len(), 1);
let ok = "unsafe { std::env::set_var(\"TS_DISABLE_PORTMAPPER\", \"1\") }; Device::new(&c)";
assert!(integration_test_problems("tests/x.rs", ok).is_empty());
assert!(integration_test_problems("tests/x.rs", "fn no_device() {}").is_empty());
}

#[test]
fn only_allowed_runtime_files_enable_the_mapper() {
let src = "let f = ForwarderConfig {\n port_mapper: Some(settings),\n};";
assert_eq!(runtime_problems("netmon.rs", src).len(), 1);
assert_eq!(
runtime_problems("netmon.rs", "port_mapper : Some( x )").len(),
1
);
assert!(runtime_problems("portmap.rs", src).is_empty());
assert!(runtime_problems("netmon.rs", "port_mapper: None,").is_empty());
}

#[test]
fn the_stub_must_stay() {
assert_eq!(stub_problems("fn system_client() {}").len(), 1);
let ok = "cfg.lookup = Arc::new(tests::NeverLookup); panic!(\"x\")";
assert!(stub_problems(ok).is_empty());
}
}
40 changes: 33 additions & 7 deletions docs/PORTMAPPER.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
# NAT port mapper (NAT-PMP and PCP)

[`ts_portmapper`](../ts_portmapper/src/lib.rs) asks the LAN gateway for an inbound UDP mapping to
the node's underlay port, as Go Tailscale's `net/portmapper` does, so that a peer behind a hard
NAT can reach the node directly instead of over DERP. NAT-PMP (RFC 6886) and PCP (RFC 6887) are
implemented. UPnP-IGD is not (bead `tsr-x4yg`, phase 2).
The node asks its LAN gateway for an inbound UDP mapping to its underlay port, as Go Tailscale's
`net/portmapper` does. It then advertises the mapping as endpoint type 3 (`PortMapped`), so a peer
behind a hard NAT can reach it directly instead of over DERP. NAT-PMP (RFC 6886) and PCP
(RFC 6887) are implemented. UPnP-IGD is not (bead `tsr-x4yg`, phase 2).

The crate holds the lifecycle client, the codecs, the gateway lookup and `GatewayCap`, the only
code that sends packets. (The gateway lookup's kernel source-address check connects a UDP socket
but never sends on it.) Nothing in the node runs it yet.
- Crate: [`ts_portmapper`](../ts_portmapper/src/lib.rs). It holds the lifecycle client, the
codecs, the gateway lookup and `GatewayCap`, the only code that sends packets. (The gateway
lookup's kernel source-address check connects a UDP socket but never sends on it.)
- Driver: [`ts_runtime::portmap`](../ts_runtime/src/portmap.rs), spawned by the direct manager.

## Turning it off

| Source | Effect |
|---|---|
| `Config::disable_port_mapper` (Go `DisablePortMapper`) | No client, no driver, no socket; NetInfo facets unset |
| `TS_DISABLE_PORTMAPPER` (Go name), read once at startup | No client, no driver, no socket; NetInfo facets unset. Go keeps the client and reports `UPnP`/`PMP`/`PCP` as false |
| Control `only-tcp-443` | Live: every LAN send stops at once, deletes included; facets `PMP`/`PCP` are false. Go only blocks new creates and probes (see Deviations) |
| Cargo feature `portmapper` off | Not compiled; facets unset |

The feature is on by default, as in Go. Tests keep it off: every shared runtime test configuration
sets `port_mapper: None`, and the `portmapper_test_isolation` check enforces the rest.

## Deviations from Go

Expand All @@ -17,6 +30,8 @@ but never sends on it.) Nothing in the node runs it yet.
- **Renewal while idle (`renew_while_idle`).** The default is Go's: renewal rides on activity (the
periodic ReSTUN and other kicks). With the option on, a due mapping is renewed even while idle,
as long as there are peers. With no peers a mapping is never renewed and expires.
- **`HavePortMap` means an advertised mapping**, so control never sees it without a type-3
endpoint.
- **Notifications.** Go publishes every successful mapping (NAT-PMP, PCP, UPnP, including a
reused one) and never a loss, expiry or disable. Here loss, expiry and disable publish too, for
both protocols.
Expand Down Expand Up @@ -53,6 +68,17 @@ but never sends on it.) Nothing in the node runs it yet.
10/30/60/120/300/600 s (±20 %), no two attempts start within 2 s, and a PCP error lifetime is
honoured up to 30 min; while that backoff holds, a probe after one that found nothing is
skipped too. This bounds LAN traffic to a refusing or absent router.
- **`only-tcp-443`.** Every LAN send stops, the mapping is withdrawn and nothing is deleted until
the knob is lifted. Go only blocks new creates and probes; it keeps the cached mapping advertised
and still releases it on Close.
- **Same-port rebind.** The endpoint is withdrawn at once and restored after the network
re-validates; Go's `SetLocalPort` is a no-op for an unchanged port and keeps the mapping.
- **No endpoint tracker.** An expired or rebound mapping leaves the endpoint list immediately; Go
keeps advertising a vanished endpoint for 5 min 10 s.
- **Network poll.** The default route is re-read every 10 s instead of subscribing to the OS link
monitor (not ported yet).
- **Resume detection.** A wall-vs-monotonic disagreement over 30 s on the 10 s poll; Go's netmon
polls the wall clock every 15 s and reacts to a jump over 22.5 s.
- **Probe and MAP exchange.** The probe returns as soon as NAT-PMP and PCP are both decided (Go
waits the full 250 ms unless UPnP also answered), and the NAT-PMP MAP always sends the
public-address request alongside (Go sends it only when it has no recent public address).
Expand Down
45 changes: 45 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use ts_keys::PersistState;

use crate::keys::NodeState;

/// The port mapper's options (see [`Config::port_mapper`]).
pub use ts_control::PortMapperOptions;

const CONTROL_URL_VAR: &str = "TS_CONTROL_URL";
const HOSTNAME_VAR: &str = "TS_HOSTNAME";
const AUTHKEY_VAR: &str = "TS_AUTH_KEY";
Expand Down Expand Up @@ -272,6 +275,20 @@ pub struct Config {
/// fail-closed underlay posture (`enable_ipv6` alone widens the family) is unchanged.
pub wireguard_listen_port: Option<u16>,

/// Turn the NAT port mapper off (Go `magicsock.Options.DisablePortMapper`). Defaults to
/// `false`: like Go, the node asks the LAN gateway for an inbound UDP mapping with NAT-PMP or
/// PCP, so peers behind a hard NAT can reach it directly. With `true` (or
/// `TS_DISABLE_PORTMAPPER` set in the environment) no client, driver or socket exists and no
/// port-mapping NetInfo is sent. Builds without the `portmapper` feature never map.
///
/// A port-mapped endpoint exposes the host's public address to tailnet peers, as STUN
/// endpoints do; a proxy-egress (`exit_proxy`) deployment may want it off.
pub disable_port_mapper: bool,

/// The port mapper's options: a validated gateway override, whether to advertise a non-public
/// external address (default yes, Go), and whether to renew while idle (default no, Go).
pub port_mapper: PortMapperOptions,

/// How this node's **application** overlay data path is realized.
///
/// Defaults to [`TransportMode::Netstack`](ts_control::TransportMode::Netstack), the userspace
Expand Down Expand Up @@ -690,6 +707,8 @@ impl From<&Config> for ts_control::Config {
enable_ipv6: value.enable_ipv6,
network_monitor: value.network_monitor,
wireguard_listen_port: value.wireguard_listen_port,
disable_port_mapper: value.disable_port_mapper,
port_mapper: value.port_mapper.clone(),
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 @@ -740,6 +759,8 @@ impl Default for Config {
enable_ipv6: false,
network_monitor: false,
wireguard_listen_port: None,
disable_port_mapper: false,
port_mapper: ts_control::PortMapperOptions::default(),
transport_mode: ts_control::TransportMode::default(),
wire_ingress: false,
advertise_services: vec![],
Expand Down Expand Up @@ -788,6 +809,12 @@ mod tests {
enable_ipv6: true,
network_monitor: true,
wireguard_listen_port: Some(41641),
disable_port_mapper: true,
port_mapper: ts_control::PortMapperOptions {
gateway_override: Some(("192.168.1.1".parse().unwrap(), None)),
advertise_nonpublic_external: false,
renew_while_idle: true,
},
wire_ingress: true,
transport_mode: ts_control::TransportMode::Tun(ts_control::TunConfig {
name: Some("tailscale0".to_owned()),
Expand Down Expand Up @@ -849,6 +876,10 @@ mod tests {
Some(41641),
"wireguard_listen_port crosses the boundary"
);
assert!(control.disable_port_mapper, "disable_port_mapper crosses");
assert!(control.port_mapper.renew_while_idle);
assert!(!control.port_mapper.advertise_nonpublic_external);
assert!(control.port_mapper.gateway_override.is_some());
assert!(control.wire_ingress);
assert_eq!(control.advertise_services, vec!["svc:samba".to_owned()]);
assert_eq!(
Expand Down Expand Up @@ -955,6 +986,20 @@ mod tests {
/// 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.
/// The library default is Go's: the port mapper is on, advertises everything and renews
/// lazily.
#[test]
fn port_mapper_defaults_are_go_like() {
let cfg = Config::default();
assert!(!cfg.disable_port_mapper);
assert!(cfg.port_mapper.advertise_nonpublic_external);
assert!(!cfg.port_mapper.renew_while_idle);
assert!(cfg.port_mapper.gateway_override.is_none());
let control: ts_control::Config = (&cfg).into();
assert!(!control.disable_port_mapper);
assert_eq!(control.port_mapper, cfg.port_mapper);
}

#[test]
fn from_config_default_wireguard_listen_port_is_none() {
let cfg = Config::default();
Expand Down
Loading
Loading