Skip to content

wgengine/router/osrouter: SNAT to the egress interface address, not (self) - #20738

Draft
mminkus wants to merge 2 commits into
tailscale:bradfitz/freebsd_no_snatfrom
mminkus:martin/pf-snat-fix
Draft

wgengine/router/osrouter: SNAT to the egress interface address, not (self)#20738
mminkus wants to merge 2 commits into
tailscale:bradfitz/freebsd_no_snatfrom
mminkus:martin/pf-snat-fix

Conversation

@mminkus

@mminkus mminkus commented Aug 3, 2026

Copy link
Copy Markdown

The FreeBSD subnet-router NAT rule translates with -> (self):

nat on ! tailscale0 inet from 100.64.0.0/10 to any -> (self)

In pf, (self) is a round-robin pool of every address on the machine -- including tailscale0's own address and loopback -- and pf deals each new state the next address in the pool. Only flows that draw the egress interface's address work. A flow translated to any other address gets replies the far end cannot route, and hangs at SYN. With N usable addresses on the box, roughly (N-1)/N of connections through the subnet router silently fail.

Evidence

vmtest, FreeBSD 15.0 router with 4 addresses (WAN, LAN, QEMU debug NIC, tailscale0), 8 sequential HTTP requests: exactly half hang, alternating, and the pf state table names the wrong address each dead flow was translated to:

all tcp 10.0.0.102:51100 (100.64.0.1:35132) -> 10.0.0.103:8080  ESTABLISHED:ESTABLISHED
all tcp 10.0.2.15:56553  (100.64.0.1:35148) -> 10.0.0.103:8080  SYN_SENT:CLOSED
all tcp 100.64.0.2:52655 (100.64.0.1:54106) -> 10.0.0.103:8080  SYN_SENT:CLOSED

(10.0.0.102 = router LAN IP, works; 10.0.2.15 = QEMU debug NIC; 100.64.0.2 = the router's own tailscale0 address, despite the rule matching on ! tailscale0 -- the interface match constrains where the rule applies, not the address pool.)

On one of our production FreeBSD firewalls running this branch, the IPv6 twin of this rule shows 55 state creations totalling 117 packets -- about two packets per state, i.e. SYNs whose replies never came back, in production, today.

This also explains why TestSubnetRouterFreeBSD has been maddeningly flaky: a single-request test is a per-run coin flip on which pool address the one flow draws.

Fix

One rule per up, non-loopback, non-Tailscale interface, translating to that interface's own address (the form FreeBSD firewall operators write by hand), emitted per address family only where the interface holds a usable address of that family:

nat on vtnet0 inet from 100.64.0.0/10 to any -> (vtnet0)
nat on vtnet1 inet from 100.64.0.0/10 to any -> (vtnet1)

Same 8-request test: 8/8, backend sees only the router's LAN address, and the LAN interface's rule shows 8 states / 56 packets (healthy flows).

Known limitation, noted in a comment: the interface set is sampled when SNAT is enabled; hot-added interfaces aren't covered until SNAT toggles or tailscaled restarts.

Second commit: the test

TestSubnetRouterFreeBSDManyFlows opens several fresh flows and requires all of them to work. Support pieces: a /exec handler in TTA plus Env.Exec (run diagnostics on nodes the harness cannot SSH into -- the FreeBSD router here), and Env.HTTPGetErr (treat a hung request as data instead of failing the test).

Caveat: this branch carries an older snapshot of the vmtest package, from before main's qemu accel fallback / QMP retry / SSH key generation landed, so vmtests here flake under TCG (gokrazy VMs wedge at 100% CPU). The 4/8-vs-8/8 runs above were done with those harness fixes applied locally; merging current main into this branch brings them all in properly.

Split out per @bradfitz's request in #19312.

mminkus added 2 commits August 3, 2026 15:52
…self)

The FreeBSD subnet-router NAT rule translated with "-> (self)":

  nat on ! tailscale0 inet from 100.64.0.0/10 to any -> (self)

In pf, "(self)" is a round-robin pool of every address on the machine,
including tailscale0's own address and loopback, and pf deals each new
state the next address in the pool. Only flows that happen to draw the
egress interface's address work; a flow translated to any other address
gets replies the far end cannot route, and hangs at SYN. With N usable
addresses on the box, roughly (N-1)/N of connections through the subnet
router silently fail.

Observed in a natlab vmtest against a FreeBSD 15.0 subnet router with
four addresses (WAN, LAN, QEMU debug NIC, tailscale0): exactly half of
8 HTTP requests hung, alternating, and the pf state table showed the
failed flows translated to the debug NIC's address and to tailscale0's
own address:

  10.0.0.102:51100 (100.64.0.1:35132) -> 10.0.0.103:8080  ESTABLISHED
  10.0.2.15:56553  (100.64.0.1:35148) -> 10.0.0.103:8080  SYN_SENT:CLOSED
  100.64.0.2:52655 (100.64.0.1:54106) -> 10.0.0.103:8080  SYN_SENT:CLOSED

On a production FreeBSD firewall running this branch, the equivalent
IPv6 rule shows 55 state creations totalling 117 packets (about two
packets per state): SYNs whose replies never came back.

Emit one rule per up, non-loopback, non-Tailscale interface instead,
translating to that interface's own address, per address family only
where the interface holds a usable address of that family:

  nat on vtnet0 inet from 100.64.0.0/10 to any -> (vtnet0)
  nat on vtnet1 inet from 100.64.0.0/10 to any -> (vtnet1)

which is also the rule form FreeBSD firewall operators write by hand.
With this, the same 8-request test passes 8/8, and the LAN interface's
rule shows 8 states with healthy packet counts (56 packets total).

The interface set is sampled when SNAT is enabled; interfaces added
later are not covered until SNAT is toggled or tailscaled restarts.

Updates tailscale#5573

Change-Id: Ife3367124737ce5c8785ca7f920eafca593ec705
Signed-off-by: Martin Minkus <[email protected]>
The existing TestSubnetRouterFreeBSD makes a single HTTP request, which
cannot catch a NAT rule whose translation address pool is wrong on
average but right occasionally: with the previous "-> (self)" rule, pf
round-robins new states across every address on the machine, so one
request has decent odds of drawing the working address while most flows
hang. Add a test that opens several fresh flows and requires all of
them to work and to arrive at the backend from the router's LAN
address.

To let the test interrogate PF state on the router (rule counters and
the state table showing what each flow was translated to), add:

  - a /exec handler to TTA that runs a shell command on the node, for
    test diagnostics on nodes the harness has no SSH access to, and an
    Env.Exec helper that calls it;
  - Env.HTTPGetErr, a variant of HTTPGet that reports failure to the
    caller instead of failing the test, so a test can treat a hung
    request as data.

Fails 4/8 requests against the previous "-> (self)" NAT rule, with the
PF state table showing the failed flows translated to the QEMU debug
NIC's address and tailscale0's own address. Passes 8/8 with per-egress-
interface NAT rules.

Updates tailscale#5573

Change-Id: Ica94fba152a07705d47923ecedf50e1fafb857e2
Signed-off-by: Martin Minkus <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant