util/linuxfw,wgengine/router: add connmark rules for rp_filter workaround (#18860)

When a Linux system acts as an exit node or subnet router with strict
reverse path filtering (rp_filter=1), reply packets may
be dropped because they fail the RPF check. Reply packets arrive on the
WAN interface but the routing table indicates they should have arrived
on the Tailscale interface, causing the kernel to drop them.

This adds firewall rules in the mangle table to save outbound packet
marks to conntrack and restore them on reply packets before the routing
decision. When reply packets have their marks restored, the kernel uses
the correct routing table (based on the mark) and the packets pass the
rp_filter check.

Implementation adds two rules per address family (IPv4/IPv6):

- mangle/OUTPUT: Save packet marks to conntrack for NEW connections
with non-zero marks in the Tailscale fwmark range (0xff0000)

- mangle/PREROUTING: Restore marks from conntrack to packets for
ESTABLISHED,RELATED connections before routing decision and rp_filter
check

The workaround is automatically enabled when UseConnmarkForRPFilter is
set in the router configuration, which happens when subnet routes are
advertised on Linux systems.

Both iptables and nftables implementations are provided, with automatic
backend detection.

Fixes #3310
Fixes #14409
Fixes #12022
Fixes #15815
Fixes #9612

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
This commit is contained in:
Mike O'Driscoll
2026-03-04 14:09:11 -05:00
committed by GitHub
parent dab8922fcf
commit 26ef46bf81
6 changed files with 814 additions and 12 deletions
+36
View File
@@ -86,6 +86,7 @@ type linuxRouter struct {
localRoutes map[netip.Prefix]bool
snatSubnetRoutes bool
statefulFiltering bool
connmarkEnabled bool // whether connmark rules are currently enabled
netfilterMode preftype.NetfilterMode
netfilterKind string
magicsockPortV4 uint16
@@ -370,6 +371,12 @@ func (r *linuxRouter) Close() error {
r.unregNetMon()
}
r.eventClient.Close()
// Clean up connmark rules
if err := r.nfr.DelConnmarkSaveRule(); err != nil {
r.logf("warning: failed to delete connmark rules: %v", err)
}
if err := r.downInterface(); err != nil {
return err
}
@@ -479,6 +486,35 @@ func (r *linuxRouter) Set(cfg *router.Config) error {
r.statefulFiltering = cfg.StatefulFiltering
r.updateStatefulFilteringWithDockerWarning(cfg)
// Connmark rules for rp_filter compatibility.
// Always enabled when netfilter is ON to handle all rp_filter=1 scenarios
// (normal operation, exit nodes, subnet routers, and clients using exit nodes).
netfilterOn := cfg.NetfilterMode == netfilterOn
switch {
case netfilterOn == r.connmarkEnabled:
// state already correct, nothing to do.
case netfilterOn:
r.logf("enabling connmark-based rp_filter workaround")
if err := r.nfr.AddConnmarkSaveRule(); err != nil {
r.logf("warning: failed to add connmark rules (rp_filter workaround may not work): %v", err)
errs = append(errs, fmt.Errorf("enabling connmark rules: %w", err))
} else {
// Only update state on success to keep it in sync with actual rules
r.connmarkEnabled = true
}
default:
r.logf("disabling connmark-based rp_filter workaround")
if err := r.nfr.DelConnmarkSaveRule(); err != nil {
// Deletion errors are only logged, not returned, because:
// 1. Rules may not exist (e.g., first run or after manual deletion)
// 2. Failure to delete is less critical than failure to add
// 3. We still want to update state to attempt re-add on next enable
r.logf("warning: failed to delete connmark rules: %v", err)
}
// Always clear state when disabling, even if delete failed
r.connmarkEnabled = false
}
// Issue 11405: enable IP forwarding on gokrazy.
advertisingRoutes := len(cfg.SubnetRoutes) > 0
if getDistroFunc() == distro.Gokrazy && advertisingRoutes {