cmd,ipn,util,wgengine: add --exit-node-allow-wan-ports flag for incoming WAN connections

Add a new `tailscale set` flag that allows incoming WAN connections on
specified proto:port pairs to bypass exit node routing. When a node uses
an exit node, reply traffic for externally-initiated connections gets
captured by the exit node's default route, breaking any service the
machine hosts on its public IP but preserving privacy. This flag
installs port-specific conntrack-based firewall rules that marks
replies to matching inbound connections with the Tailscale bypass
fwmark (0x80000), causing them to route through the main table instead
of the exit node tunnel.

Usage: tailscale set --exit-node-allow-wan-ports=tcp:22,tcp:443 --accept-risk=wan-bypass

For each proto:port entry, two firewall rules are created:
- mangle/PREROUTING: tags connections on non-tailscale interfaces for
  the specified destination port with the bypass conntrack mark. Matches
  all connection states (not just NEW) so existing connections get
  tagged when rules are installed after an exit node is activated.
- mangle/OUTPUT: sets the bypass fwmark on ESTABLISHED/RELATED replies
  (matched by source port) so they route via the physical interface

WAN bypass rules are installed before routes in the router's Set()
method to avoid a window where the exit node route is active but no
bypass rules exist, which would drop existing connections.

Implements both iptables and nftables backends. The nftables OUTPUT
rules use a separate chain (ts-wan-bypass) with ChainTypeRoute to
trigger re-routing when the packet mark changes.

Also fixes a pre-existing byte-order bug in the nftables backend where
mark-related helper functions (getTailscaleFwmarkMask, etc.) used
hardcoded big-endian byte arrays instead of native byte order. On
little-endian systems (all x86), the nftables Bitwise expressions
operated on the wrong bits, making the connmark save/restore rules
(rp_filter workaround) silently ineffective. Changed all mark byte
helpers to use binary.NativeEndian.

Updates #10940

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
This commit is contained in:
Mike O'Driscoll 2026-04-30 19:24:58 +00:00
parent 92179b1fc7
commit a058d04afb
20 changed files with 766 additions and 34 deletions

View File

@ -16,6 +16,7 @@ var (
riskTypes []string
riskLoseSSH = registerRiskType("lose-ssh")
riskMacAppConnector = registerRiskType("mac-app-connector")
riskWANBypass = registerRiskType("wan-bypass")
riskAll = registerRiskType("all")
)
@ -25,6 +26,10 @@ You are trying to configure an app connector on macOS, which is not officially s
Do not use a macOS app connector for any mission-critical purposes. For the best experience, Linux is the only recommended platform for app connectors.
`
const riskWANBypassMessage = `
Reply traffic for incoming connections on the specified ports will bypass the exit node and go directly from this machine's physical interface. This reveals the machine's real IP address to any client that connects to those ports.
`
func registerRiskType(riskType string) string {
riskTypes = append(riskTypes, riskType)
return riskType

View File

@ -22,6 +22,7 @@ import (
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tsconst"
"tailscale.com/types/opt"
"tailscale.com/types/views"
@ -48,6 +49,7 @@ type setArgsT struct {
acceptDNS bool
exitNodeIP string
exitNodeAllowLANAccess bool
exitNodeAllowWANPorts string
shieldsUp bool
runSSH bool
runWebClient bool
@ -78,6 +80,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
setf.BoolVar(&setArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
setf.StringVar(&setArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP, base name, or auto:any) for internet traffic, or empty string to not use an exit node")
setf.BoolVar(&setArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
setf.StringVar(&setArgs.exitNodeAllowWANPorts, "exit-node-allow-wan-ports", "", "allow incoming WAN connections on specified proto:port pairs when using an exit node (comma-separated, e.g. \"tcp:22,tcp:443\")")
setf.BoolVar(&setArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
setf.BoolVar(&setArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
@ -216,6 +219,30 @@ func runSet(ctx context.Context, args []string) (retErr error) {
return err
}
}
if maskedPrefs.ExitNodeAllowWANPortsSet {
if setArgs.exitNodeAllowWANPorts != "" {
maskedPrefs.ExitNodeAllowWANPorts, err = tailcfg.ParseProtoPortRanges(strings.Split(setArgs.exitNodeAllowWANPorts, ","))
if err != nil {
return fmt.Errorf("invalid --exit-node-allow-wan-ports: %w", err)
}
if len(maskedPrefs.ExitNodeAllowWANPorts) > 128 {
return fmt.Errorf("invalid --exit-node-allow-wan-ports: too many port entries (max 128)")
}
for _, ppr := range maskedPrefs.ExitNodeAllowWANPorts {
if ppr.Proto != 0 && ppr.Proto != 6 && ppr.Proto != 17 {
return fmt.Errorf("invalid --exit-node-allow-wan-ports: only tcp and udp protocols are supported")
}
}
} else {
maskedPrefs.ExitNodeAllowWANPorts = nil
}
}
if maskedPrefs.ExitNodeAllowWANPortsSet && len(maskedPrefs.ExitNodeAllowWANPorts) > 0 {
if err := presentRiskToUser(riskWANBypass, riskWANBypassMessage, setArgs.acceptedRisks); err != nil {
return err
}
}
if runtime.GOOS == "darwin" && maskedPrefs.AppConnector.Advertise {
if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, setArgs.acceptedRisks); err != nil {

View File

@ -908,6 +908,7 @@ func init() {
addPrefFlagMapping("snat-subnet-routes", "NoSNAT")
addPrefFlagMapping("stateful-filtering", "NoStatefulFiltering")
addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess")
addPrefFlagMapping("exit-node-allow-wan-ports", "ExitNodeAllowWANPorts")
addPrefFlagMapping("unattended", "ForceDaemon")
addPrefFlagMapping("operator", "OperatorUser")
addPrefFlagMapping("ssh", "RunSSH")

View File

@ -48,6 +48,7 @@ func (src *Prefs) Clone() *Prefs {
}
dst := new(Prefs)
*dst = *src
dst.ExitNodeAllowWANPorts = append(src.ExitNodeAllowWANPorts[:0:0], src.ExitNodeAllowWANPorts...)
dst.AdvertiseTags = append(src.AdvertiseTags[:0:0], src.AdvertiseTags...)
dst.AdvertiseRoutes = append(src.AdvertiseRoutes[:0:0], src.AdvertiseRoutes...)
dst.AdvertiseServices = append(src.AdvertiseServices[:0:0], src.AdvertiseServices...)
@ -78,6 +79,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
AutoExitNode ExitNodeExpression
InternalExitNodePrior tailcfg.StableNodeID
ExitNodeAllowLANAccess bool
ExitNodeAllowWANPorts []tailcfg.ProtoPortRange
CorpDNS bool
RunSSH bool
RunWebClient bool

View File

@ -282,6 +282,15 @@ func (v PrefsView) InternalExitNodePrior() tailcfg.StableNodeID { return v.ж.In
// routed directly or via the exit node.
func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess }
// ExitNodeAllowWANPorts specifies proto:port pairs for which incoming
// WAN connections should bypass exit node routing. When set, reply
// traffic for connections arriving on these ports is routed directly
// via the physical interface instead of through the exit node tunnel.
// Linux-only.
func (v PrefsView) ExitNodeAllowWANPorts() views.Slice[tailcfg.ProtoPortRange] {
return views.SliceOf(v.ж.ExitNodeAllowWANPorts)
}
// CorpDNS specifies whether to install the Tailscale network's
// DNS configuration, if it exists.
func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS }
@ -480,6 +489,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
AutoExitNode ExitNodeExpression
InternalExitNodePrior tailcfg.StableNodeID
ExitNodeAllowLANAccess bool
ExitNodeAllowWANPorts []tailcfg.ProtoPortRange
CorpDNS bool
RunSSH bool
RunWebClient bool

View File

@ -5767,14 +5767,15 @@ func (b *LocalBackend) routerConfigLocked(cfg *wgcfg.Config, prefs ipn.PrefsView
}
rs := &router.Config{
LocalAddrs: unmapIPPrefixes(cfg.Addresses),
SubnetRoutes: unmapIPPrefixes(prefs.AdvertiseRoutes().AsSlice()),
SNATSubnetRoutes: !prefs.NoSNAT(),
StatefulFiltering: doStatefulFiltering,
NetfilterMode: prefs.NetfilterMode(),
Routes: peerRoutes(b.logf, cfg.Peers, singleRouteThreshold, prefs.RouteAll()),
NetfilterKind: netfilterKind,
RemoveCGNATDropRule: nm.HasCap(tailcfg.NodeAttrDisableLinuxCGNATDropRule),
LocalAddrs: unmapIPPrefixes(cfg.Addresses),
SubnetRoutes: unmapIPPrefixes(prefs.AdvertiseRoutes().AsSlice()),
SNATSubnetRoutes: !prefs.NoSNAT(),
StatefulFiltering: doStatefulFiltering,
ExitNodeAllowWANPorts: prefs.ExitNodeAllowWANPorts().AsSlice(),
NetfilterMode: prefs.NetfilterMode(),
Routes: peerRoutes(b.logf, cfg.Peers, singleRouteThreshold, prefs.RouteAll()),
NetfilterKind: netfilterKind,
RemoveCGNATDropRule: nm.HasCap(tailcfg.NodeAttrDisableLinuxCGNATDropRule),
}
if buildfeatures.HasSynology && distro.Get() == distro.Synology {

View File

@ -128,6 +128,13 @@ type Prefs struct {
// routed directly or via the exit node.
ExitNodeAllowLANAccess bool
// ExitNodeAllowWANPorts specifies proto:port pairs for which incoming
// WAN connections should bypass exit node routing. When set, reply
// traffic for connections arriving on these ports is routed directly
// via the physical interface instead of through the exit node tunnel.
// Linux-only.
ExitNodeAllowWANPorts []tailcfg.ProtoPortRange
// CorpDNS specifies whether to install the Tailscale network's
// DNS configuration, if it exists.
CorpDNS bool
@ -360,6 +367,7 @@ type MaskedPrefs struct {
AutoExitNodeSet bool `json:",omitempty"`
InternalExitNodePriorSet bool `json:",omitempty"` // Internal; can't be set by LocalAPI clients
ExitNodeAllowLANAccessSet bool `json:",omitempty"`
ExitNodeAllowWANPortsSet bool `json:",omitzero"`
CorpDNSSet bool `json:",omitempty"`
RunSSHSet bool `json:",omitempty"`
RunWebClientSet bool `json:",omitempty"`
@ -575,6 +583,9 @@ func (p *Prefs) pretty(goos string) string {
} else if !p.ExitNodeID.IsZero() {
fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeID, p.ExitNodeAllowLANAccess)
}
if len(p.ExitNodeAllowWANPorts) > 0 {
fmt.Fprintf(&sb, "wanPorts=%v ", p.ExitNodeAllowWANPorts)
}
if p.AutoExitNode.IsSet() {
fmt.Fprintf(&sb, "auto=%v ", p.AutoExitNode)
}
@ -667,6 +678,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.AutoExitNode == p2.AutoExitNode &&
p.InternalExitNodePrior == p2.InternalExitNodePrior &&
p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
slices.Equal(p.ExitNodeAllowWANPorts, p2.ExitNodeAllowWANPorts) &&
p.CorpDNS == p2.CorpDNS &&
p.RunSSH == p2.RunSSH &&
p.Sync.Normalized() == p2.Sync.Normalized() &&

View File

@ -44,6 +44,7 @@ func TestPrefsEqual(t *testing.T) {
"AutoExitNode",
"InternalExitNodePrior",
"ExitNodeAllowLANAccess",
"ExitNodeAllowWANPorts",
"CorpDNS",
"RunSSH",
"RunWebClient",
@ -390,6 +391,21 @@ func TestPrefsEqual(t *testing.T) {
&Prefs{RelayServerStaticEndpoints: aps("[2001:db8::1]:40000", "192.0.2.1:40000")},
false,
},
{
&Prefs{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
&Prefs{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
true,
},
{
&Prefs{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
&Prefs{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 22, Last: 22}}}},
false,
},
{
&Prefs{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
&Prefs{ExitNodeAllowWANPorts: nil},
false,
},
}
for i, tt := range tests {
got := tt.a.Equals(tt.b)

View File

@ -8,6 +8,7 @@ package linuxfw
import (
"net/netip"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
)
@ -71,8 +72,10 @@ func (f *FakeNetfilterRunner) AddHooks() error { retur
func (f *FakeNetfilterRunner) DelHooks(logf logger.Logf) error { return nil }
func (f *FakeNetfilterRunner) AddSNATRule() error { return nil }
func (f *FakeNetfilterRunner) DelSNATRule() error { return nil }
func (f *FakeNetfilterRunner) AddConnmarkSaveRule() error { return nil }
func (f *FakeNetfilterRunner) DelConnmarkSaveRule() error { return nil }
func (f *FakeNetfilterRunner) AddConnmarkSaveRule() error { return nil }
func (f *FakeNetfilterRunner) DelConnmarkSaveRule() error { return nil }
func (f *FakeNetfilterRunner) AddWANBypassRule(tunname string, ports []tailcfg.ProtoPortRange) error { return nil }
func (f *FakeNetfilterRunner) DelWANBypassRule(tunname string) error { return nil }
func (f *FakeNetfilterRunner) AddStatefulRule(tunname string) error { return nil }
func (f *FakeNetfilterRunner) DelStatefulRule(tunname string) error { return nil }
func (f *FakeNetfilterRunner) AddLoopbackRule(addr netip.Addr) error { return nil }

View File

@ -15,6 +15,8 @@ import (
"strings"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/ipproto"
"tailscale.com/types/logger"
)
@ -610,6 +612,111 @@ func (i *iptablesRunner) DelConnmarkSaveRule() error {
return nil
}
const wanBypassComment = "ts-wan-bypass"
// iptPortRange returns the iptables port range string for a PortRange.
// iptables uses ":" as the range separator (e.g., "80:443"), while
// tailcfg.PortRange.String() uses "-".
func iptPortRange(pr tailcfg.PortRange) string {
return strings.ReplaceAll(pr.String(), "-", ":")
}
// AddWANBypassRule adds port-specific conntrack rules that allow incoming WAN
// connections on the specified proto:port pairs to bypass exit node routing.
func (i *iptablesRunner) AddWANBypassRule(tunname string, ports []tailcfg.ProtoPortRange) error {
// Clear any existing rules first for clean state.
if err := i.DelWANBypassRule(tunname); err != nil {
return err
}
for _, ppr := range ports {
// Expand Proto=0 (all) into tcp and udp separately since ICMP has no ports.
protos := []int{ppr.Proto}
if ppr.Proto == 0 {
protos = []int{6, 17}
}
pr := iptPortRange(ppr.Ports)
for _, proto := range protos {
pnBytes, err := ipproto.Proto(proto).MarshalText()
if err != nil {
return fmt.Errorf("unsupported protocol %d: %w", proto, err)
}
pn := string(pnBytes)
// mangle/PREROUTING: tag connections on non-tailscale interfaces
// for the specified port. Matches all states (not just NEW) so
// that existing connections get tagged when rules are installed
// after an exit node is activated.
for _, ipt := range i.getTables() {
args := []string{
"!", "-i", tunname,
"-p", pn, "--dport", pr,
"-m", "comment", "--comment", wanBypassComment,
"-j", "CONNMARK", "--set-mark", bypassMark + "/" + fwmarkMask,
}
if err := ipt.Append("mangle", "PREROUTING", args...); err != nil {
return fmt.Errorf("adding WAN bypass PREROUTING rule for %s:%s: %w", pn, pr, err)
}
}
// mangle/OUTPUT: set bypass fwmark on replies to tagged connections
for _, ipt := range i.getTables() {
args := []string{
"-p", pn, "--sport", pr,
"-m", "connmark", "--mark", bypassMark + "/" + fwmarkMask,
"-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED",
"-m", "comment", "--comment", wanBypassComment,
"-j", "MARK", "--set-mark", bypassMark + "/" + fwmarkMask,
}
if err := ipt.Append("mangle", "OUTPUT", args...); err != nil {
return fmt.Errorf("adding WAN bypass OUTPUT rule for %s:%s: %w", pn, pr, err)
}
}
}
}
return nil
}
// DelWANBypassRule removes all rules added by AddWANBypassRule by scanning for
// rules with the ts-wan-bypass comment marker.
func (i *iptablesRunner) DelWANBypassRule(tunname string) error {
for _, ipt := range i.getTables() {
for _, chain := range []string{"PREROUTING", "OUTPUT"} {
// Repeatedly scan and delete until no more ts-wan-bypass rules remain,
// since indices shift after each deletion.
for {
rules, err := ipt.List("mangle", chain)
if err != nil {
break
}
found := false
for _, rule := range rules {
if !strings.Contains(rule, wanBypassComment) {
continue
}
found = true
// Strip leading "-A <CHAIN> " prefix if present (real iptables adds it).
args := strings.Fields(rule)
if len(args) >= 2 && args[0] == "-A" {
args = args[2:]
}
if err := ipt.Delete("mangle", chain, args...); err != nil {
if !isNotExistError(err) {
return fmt.Errorf("deleting WAN bypass rule in mangle/%s: %w", chain, err)
}
}
break // restart scan since indices shifted
}
if !found {
break
}
}
}
}
return nil
}
// buildMagicsockPortRule generates the string slice containing the arguments
// to describe a rule accepting traffic on a particular port to iptables. It is
// separated out here to avoid repetition in AddMagicsockPortRule and

View File

@ -11,6 +11,7 @@ import (
"testing"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tsconst"
)
@ -503,6 +504,114 @@ func TestAddAndDelConnmarkSaveRule(t *testing.T) {
})
}
func TestAddAndDelWANBypassRule(t *testing.T) {
tunname := "tailscale0"
ports := []tailcfg.ProtoPortRange{
{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}},
}
preroutingArgs := []string{
"!", "-i", tunname,
"-p", "tcp", "--dport", "443",
"-m", "comment", "--comment", wanBypassComment,
"-j", "CONNMARK", "--set-mark", "0x80000/0xff0000",
}
outputArgs := []string{
"-p", "tcp", "--sport", "443",
"-m", "connmark", "--mark", "0x80000/0xff0000",
"-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED",
"-m", "comment", "--comment", wanBypassComment,
"-j", "MARK", "--set-mark", "0x80000/0xff0000",
}
t.Run("add_and_delete", func(t *testing.T) {
iptr := newFakeIPTablesRunner()
if err := iptr.AddWANBypassRule(tunname, ports); err != nil {
t.Fatalf("AddWANBypassRule failed: %v", err)
}
// Verify rules exist in both IPv4 and IPv6
for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} {
if exists, err := proto.Exists("mangle", "PREROUTING", preroutingArgs...); err != nil {
t.Fatalf("error checking PREROUTING rule: %v", err)
} else if !exists {
t.Errorf("PREROUTING WAN bypass rule doesn't exist")
}
if exists, err := proto.Exists("mangle", "OUTPUT", outputArgs...); err != nil {
t.Fatalf("error checking OUTPUT rule: %v", err)
} else if !exists {
t.Errorf("OUTPUT WAN bypass rule doesn't exist")
}
}
// Delete
if err := iptr.DelWANBypassRule(tunname); err != nil {
t.Fatalf("DelWANBypassRule failed: %v", err)
}
for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} {
if exists, err := proto.Exists("mangle", "PREROUTING", preroutingArgs...); err != nil {
t.Fatalf("error checking PREROUTING rule: %v", err)
} else if exists {
t.Errorf("PREROUTING WAN bypass rule still exists after deletion")
}
if exists, err := proto.Exists("mangle", "OUTPUT", outputArgs...); err != nil {
t.Fatalf("error checking OUTPUT rule: %v", err)
} else if exists {
t.Errorf("OUTPUT WAN bypass rule still exists after deletion")
}
}
// Idempotent deletion
if err := iptr.DelWANBypassRule(tunname); err != nil {
t.Fatalf("DelWANBypassRule (second call) failed: %v", err)
}
})
t.Run("port_update", func(t *testing.T) {
iptr := newFakeIPTablesRunner()
// Add tcp:443
if err := iptr.AddWANBypassRule(tunname, ports); err != nil {
t.Fatalf("AddWANBypassRule failed: %v", err)
}
// Update to tcp:22
newPorts := []tailcfg.ProtoPortRange{
{Proto: 6, Ports: tailcfg.PortRange{First: 22, Last: 22}},
}
if err := iptr.AddWANBypassRule(tunname, newPorts); err != nil {
t.Fatalf("AddWANBypassRule (update) failed: %v", err)
}
// Old rule should be gone
for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} {
if exists, err := proto.Exists("mangle", "PREROUTING", preroutingArgs...); err != nil {
t.Fatalf("error checking old PREROUTING rule: %v", err)
} else if exists {
t.Errorf("old PREROUTING rule for tcp:443 still exists after port update")
}
}
// New rule should exist
newPreroutingArgs := []string{
"!", "-i", tunname,
"-p", "tcp", "--dport", "22",
"-m", "comment", "--comment", wanBypassComment,
"-j", "CONNMARK", "--set-mark", "0x80000/0xff0000",
}
for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} {
if exists, err := proto.Exists("mangle", "PREROUTING", newPreroutingArgs...); err != nil {
t.Fatalf("error checking new PREROUTING rule: %v", err)
} else if !exists {
t.Errorf("new PREROUTING rule for tcp:22 doesn't exist")
}
}
})
}
func TestAddAndDelCGNATRules(t *testing.T) {
iptr := newFakeIPTablesRunner()
tunname := "tun0"

View File

@ -7,6 +7,7 @@
package linuxfw
import (
"encoding/binary"
"errors"
"fmt"
"os"
@ -86,19 +87,33 @@ const (
bypassMarkNum = tsconst.LinuxBypassMarkNum
)
// getTailscaleFwmarkMaskNeg returns the negation of TailscaleFwmarkMask in bytes.
// getTailscaleFwmarkMaskNeg returns the negation of TailscaleFwmarkMask
// in native byte order.
func getTailscaleFwmarkMaskNeg() []byte {
return []byte{0xff, 0x00, 0xff, 0xff}
return nativeEndianUint32(^uint32(fwmarkMaskNum))
}
// getTailscaleFwmarkMask returns the TailscaleFwmarkMask in bytes.
// getTailscaleFwmarkMask returns the TailscaleFwmarkMask in native byte order.
func getTailscaleFwmarkMask() []byte {
return []byte{0x00, 0xff, 0x00, 0x00}
return nativeEndianUint32(fwmarkMaskNum)
}
// getTailscaleSubnetRouteMark returns the TailscaleSubnetRouteMark in bytes.
// getTailscaleSubnetRouteMark returns the TailscaleSubnetRouteMark
// in native byte order.
func getTailscaleSubnetRouteMark() []byte {
return []byte{0x00, 0x04, 0x00, 0x00}
return nativeEndianUint32(subnetRouteMarkNum)
}
// getTailscaleBypassMark returns the TailscaleBypassMark in native byte order.
func getTailscaleBypassMark() []byte {
return nativeEndianUint32(bypassMarkNum)
}
// nativeEndianUint32 returns v as a 4-byte slice in the host's native byte order.
func nativeEndianUint32(v uint32) []byte {
b := make([]byte, 4)
binary.NativeEndian.PutUint32(b, v)
return b
}
// checkIPv6ForTest can be set in tests.

View File

@ -19,6 +19,7 @@ import (
"github.com/google/nftables/expr"
"golang.org/x/sys/unix"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
)
@ -534,6 +535,19 @@ type NetfilterRunner interface {
// DelConnmarkSaveRule removes conntrack marking rules added by AddConnmarkSaveRule.
DelConnmarkSaveRule() error
// AddWANBypassRule adds port-specific conntrack rules that allow incoming
// WAN connections on the specified proto:port pairs to bypass exit node
// routing. In mangle/PREROUTING, all packets matching the ports on
// non-tailscale interfaces get their conntrack mark set to the bypass
// fwmark (matching all connection states, not just NEW, so that existing
// connections get tagged when rules are installed after an exit node is
// activated). In mangle/OUTPUT, ESTABLISHED/RELATED replies to those
// connections get their packet mark set to the bypass fwmark.
AddWANBypassRule(tunname string, ports []tailcfg.ProtoPortRange) error
// DelWANBypassRule removes all rules added by AddWANBypassRule.
DelWANBypassRule(tunname string) error
// HasIPV6 reports true if the system supports IPv6.
HasIPV6() bool
@ -1917,9 +1931,7 @@ func (n *nftablesRunner) DelSNATRule() error {
}
func nativeUint32(v uint32) []byte {
b := make([]byte, 4)
binary.NativeEndian.PutUint32(b, v)
return b
return nativeEndianUint32(v)
}
func makeStatefulRuleExprs(tunname string) []expr.Any {
@ -2213,6 +2225,267 @@ func makeConnmarkSaveExprs() []expr.Any {
}
}
// makeWANBypassPreroutingExprs creates nftables expressions for the PREROUTING
// rule that tags connections on non-tailscale interfaces for a specific
// proto:port with the bypass conntrack mark. Matches all connection states
// (not just NEW) so existing connections get tagged when rules are installed.
// Implements: iifname != <tunname> meta l4proto <proto> th dport <port> ct mark set 0x80000
func makeWANBypassPreroutingExprs(tunname string, proto uint8, portFirst, portLast uint16) []expr.Any {
exprs := []expr.Any{
// Match: iifname != tunname
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
Data: []byte(tunname + "\x00"),
},
// Match: meta l4proto
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte{proto},
},
// Match: th dport (transport header offset 2, length 2)
&expr.Payload{
DestRegister: 1,
Base: expr.PayloadBaseTransportHeader,
Offset: 2,
Len: 2,
},
}
if portFirst == portLast {
exprs = append(exprs, &expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: binary.BigEndian.AppendUint16(nil, portFirst),
})
} else {
exprs = append(exprs,
&expr.Cmp{
Op: expr.CmpOpGte,
Register: 1,
Data: binary.BigEndian.AppendUint16(nil, portFirst),
},
&expr.Cmp{
Op: expr.CmpOpLte,
Register: 1,
Data: binary.BigEndian.AppendUint16(nil, portLast),
},
)
}
exprs = append(exprs,
// Action: ct mark set bypass mark
&expr.Immediate{Register: 1, Data: getTailscaleBypassMark()},
&expr.Ct{
Key: expr.CtKeyMARK,
SourceRegister: true,
Register: 1,
},
)
return exprs
}
// makeWANBypassOutputExprs creates nftables expressions for the OUTPUT rule
// that sets the bypass fwmark on replies to tagged connections for a specific
// proto:port.
// Implements: meta l4proto <proto> th sport <port> ct mark & 0xff0000 == 0x80000 ct state established,related meta mark set 0x80000
func makeWANBypassOutputExprs(proto uint8, portFirst, portLast uint16) []expr.Any {
exprs := []expr.Any{
// Match: meta l4proto
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte{proto},
},
// Match: th sport (transport header offset 0, length 2)
&expr.Payload{
DestRegister: 1,
Base: expr.PayloadBaseTransportHeader,
Offset: 0,
Len: 2,
},
}
if portFirst == portLast {
exprs = append(exprs, &expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: binary.BigEndian.AppendUint16(nil, portFirst),
})
} else {
exprs = append(exprs,
&expr.Cmp{
Op: expr.CmpOpGte,
Register: 1,
Data: binary.BigEndian.AppendUint16(nil, portFirst),
},
&expr.Cmp{
Op: expr.CmpOpLte,
Register: 1,
Data: binary.BigEndian.AppendUint16(nil, portLast),
},
)
}
exprs = append(exprs,
// Match: ct mark & 0xff0000 == 0x80000
&expr.Ct{Register: 1, Key: expr.CtKeyMARK},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4,
Mask: getTailscaleFwmarkMask(),
Xor: []byte{0x00, 0x00, 0x00, 0x00},
},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: getTailscaleBypassMark(),
},
// Match: ct state established,related
&expr.Ct{Register: 1, Key: expr.CtKeySTATE},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4,
Mask: nativeUint32(expr.CtStateBitESTABLISHED | expr.CtStateBitRELATED),
Xor: nativeUint32(0),
},
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
Data: []byte{0, 0, 0, 0},
},
// Action: meta mark set (meta mark & ~fwmarkMask) | bypassMark
// Uses Bitwise to preserve non-Tailscale mark bits.
&expr.Meta{Key: expr.MetaKeyMARK, Register: 1},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4,
Mask: getTailscaleFwmarkMaskNeg(),
Xor: getTailscaleBypassMark(),
},
&expr.Meta{
Key: expr.MetaKeyMARK,
SourceRegister: true,
Register: 1,
},
)
return exprs
}
// wanBypassChainName is the name of the nftables chain used for WAN bypass
// OUTPUT rules. It uses ChainTypeRoute to trigger re-routing when the packet
// mark changes.
const wanBypassChainName = "ts-wan-bypass"
// AddWANBypassRule adds port-specific conntrack rules for WAN bypass.
func (n *nftablesRunner) AddWANBypassRule(tunname string, ports []tailcfg.ProtoPortRange) error {
// Clear existing rules first. DelWANBypassRule is lenient (always
// returns nil) to support idempotent cleanup when rules don't exist.
n.DelWANBypassRule(tunname)
conn := n.conn
for _, table := range n.getTables() {
mangleTable := &nftables.Table{Family: table.Proto, Name: "mangle"}
conn.AddTable(mangleTable)
// Get or create PREROUTING chain.
preroutingChain, err := getChainFromTable(conn, mangleTable, "PREROUTING")
if err != nil {
preroutingChain = conn.AddChain(&nftables.Chain{
Name: "PREROUTING",
Table: mangleTable,
Type: nftables.ChainTypeFilter,
Hooknum: nftables.ChainHookPrerouting,
Priority: nftables.ChainPriorityMangle,
})
}
// Get or create ts-wan-bypass OUTPUT chain with type route for re-routing.
wanBypassPri := nftables.ChainPriorityRef(-149) // mangle (-150) + 1
wanBypassChain, err := getChainFromTable(conn, mangleTable, wanBypassChainName)
if err != nil {
wanBypassChain = conn.AddChain(&nftables.Chain{
Name: wanBypassChainName,
Table: mangleTable,
Type: nftables.ChainTypeRoute,
Hooknum: nftables.ChainHookOutput,
Priority: wanBypassPri,
})
}
for _, ppr := range ports {
// Expand Proto=0 into tcp and udp.
protos := []uint8{uint8(ppr.Proto)}
if ppr.Proto == 0 {
protos = []uint8{6, 17}
}
for _, proto := range protos {
tag := fmt.Sprintf("ts-wan-bypass-%d-%d", proto, ppr.Ports.First)
// PREROUTING rule
conn.AddRule(&nftables.Rule{
Table: mangleTable,
Chain: preroutingChain,
Exprs: makeWANBypassPreroutingExprs(tunname, proto, ppr.Ports.First, ppr.Ports.Last),
UserData: []byte("pre-" + tag),
})
// OUTPUT rule in ts-wan-bypass chain
conn.AddRule(&nftables.Rule{
Table: mangleTable,
Chain: wanBypassChain,
Exprs: makeWANBypassOutputExprs(proto, ppr.Ports.First, ppr.Ports.Last),
UserData: []byte("out-" + tag),
})
}
}
}
if err := conn.Flush(); err != nil {
return fmt.Errorf("flush add WAN bypass rules: %w", err)
}
return nil
}
// DelWANBypassRule removes all WAN bypass rules.
func (n *nftablesRunner) DelWANBypassRule(tunname string) error {
conn := n.conn
for _, table := range n.getTables() {
mangleTable := &nftables.Table{Family: table.Proto, Name: "mangle"}
// Remove PREROUTING rules with ts-wan-bypass prefix.
preroutingChain, err := getChainFromTable(conn, mangleTable, "PREROUTING")
if err == nil {
rules, _ := conn.GetRules(preroutingChain.Table, preroutingChain)
for _, rule := range rules {
if strings.HasPrefix(string(rule.UserData), "pre-ts-wan-bypass-") {
conn.DelRule(rule)
}
}
}
// Remove ts-wan-bypass chain and its rules.
wanBypassChain, err := getChainFromTable(conn, mangleTable, wanBypassChainName)
if err == nil {
rules, _ := conn.GetRules(wanBypassChain.Table, wanBypassChain)
for _, rule := range rules {
conn.DelRule(rule)
}
conn.FlushChain(wanBypassChain)
conn.DelChain(wanBypassChain)
}
}
// Ignore errors during deletion — rules/chains may not exist.
// Matches the pattern used by DelConnmarkSaveRule.
conn.Flush()
return nil
}
// AddConnmarkSaveRule adds conntrack marking rules to save and restore marks.
// These rules run in mangle/PREROUTING (to restore marks from conntrack) and
// mangle/OUTPUT (to save marks to conntrack) before rp_filter checks, enabling

View File

@ -1,6 +1,10 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Byte-level regression tests in this file encode expected netlink messages
// in native (little-endian) byte order for mark values. All Linux platforms
// Tailscale targets (amd64, arm64, arm, riscv64) are little-endian.
//go:build linux
package linuxfw
@ -298,7 +302,7 @@ func TestAddSetSubnetRouteMarkRule(t *testing.T) {
// nft add chain ip ts-filter-test ts-forward-test { type filter hook forward priority 0\; }
[]byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x03\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x02\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"),
// nft add rule ip ts-filter-test ts-forward-test iifname "testTunn" counter meta mark set mark and 0xff00ffff xor 0x40000
[]byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x10\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\xff\x00\xff\xff\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x04\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x03\x00\x00\x00\x00\x01"),
[]byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x10\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\xff\xff\x00\xff\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x04\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x03\x00\x00\x00\x00\x01"),
// batch end
[]byte("\x00\x00\x00\x0a"),
}
@ -426,7 +430,7 @@ func TestAddMatchSubnetRouteMarkRuleMasq(t *testing.T) {
// nft add chain ip ts-nat-test ts-postrouting-test { type nat hook postrouting priority 100; }
[]byte("\x02\x00\x00\x00\x10\x00\x01\x00\x74\x73\x2d\x6e\x61\x74\x2d\x74\x65\x73\x74\x00\x18\x00\x03\x00\x74\x73\x2d\x70\x6f\x73\x74\x72\x6f\x75\x74\x69\x6e\x67\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x04\x08\x00\x02\x00\x00\x00\x00\x64\x08\x00\x07\x00\x6e\x61\x74\x00"),
// nft add rule ip ts-nat-test ts-postrouting-test meta mark & 0x00ff0000 == 0x00040000 counter masquerade
[]byte("\x02\x00\x00\x00\x10\x00\x01\x00\x74\x73\x2d\x6e\x61\x74\x2d\x74\x65\x73\x74\x00\x18\x00\x02\x00\x74\x73\x2d\x70\x6f\x73\x74\x72\x6f\x75\x74\x69\x6e\x67\x2d\x74\x65\x73\x74\x00\xd8\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\xff\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x04\x00\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14\x00\x01\x80\x09\x00\x01\x00\x6d\x61\x73\x71\x00\x00\x00\x00\x04\x00\x02\x80"),
[]byte("\x02\x00\x00\x00\x10\x00\x01\x00\x74\x73\x2d\x6e\x61\x74\x2d\x74\x65\x73\x74\x00\x18\x00\x02\x00\x74\x73\x2d\x70\x6f\x73\x74\x72\x6f\x75\x74\x69\x6e\x67\x2d\x74\x65\x73\x74\x00\xd8\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\x00\xff\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x04\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14\x00\x01\x80\x09\x00\x01\x00\x6d\x61\x73\x71\x00\x00\x00\x00\x04\x00\x02\x80"),
// batch end
[]byte("\x00\x00\x00\x0a"),
}
@ -497,7 +501,7 @@ func TestAddMatchSubnetRouteMarkRuleAccept(t *testing.T) {
// nft add chain ip ts-filter-test ts-forward-test { type filter hook forward priority 0\; }
[]byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x03\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x02\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"),
// nft add rule ip ts-filter-test ts-forward-test meta mark and 0x00ff0000 eq 0x00040000 counter accept
[]byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\xf4\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\xff\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x04\x00\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"),
[]byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\xf4\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\x00\xff\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x04\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"),
// batch end
[]byte("\x00\x00\x00\x0a"),
}
@ -1318,7 +1322,7 @@ func TestMakeConnmarkRestoreExprs(t *testing.T) {
// nft add chain ip mangle PREROUTING { type filter hook prerouting priority mangle; }
[]byte("\x02\x00\x00\x00\x0b\x00\x01\x00\x6d\x61\x6e\x67\x6c\x65\x00\x00\x0f\x00\x03\x00\x50\x52\x45\x52\x4f\x55\x54\x49\x4e\x47\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x00\x08\x00\x02\x00\xff\xff\xff\x6a\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"),
// nft add rule ip mangle PREROUTING ct state established,related ct mark & 0xff0000 != 0 meta mark set ct mark & 0xff0000
[]byte("\x02\x00\x00\x00\x0b\x00\x01\x00\x6d\x61\x6e\x67\x6c\x65\x00\x00\x0f\x00\x02\x00\x50\x52\x45\x52\x4f\x55\x54\x49\x4e\x47\x00\x00\x1c\x01\x04\x80\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x00\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x06\x00\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x00\x00\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\xff\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x03\x00\x00\x00\x00\x01"),
[]byte("\x02\x00\x00\x00\x0b\x00\x01\x00\x6d\x61\x6e\x67\x6c\x65\x00\x00\x0f\x00\x02\x00\x50\x52\x45\x52\x4f\x55\x54\x49\x4e\x47\x00\x00\x1c\x01\x04\x80\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x00\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x06\x00\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x00\x00\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\x00\xff\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x03\x00\x00\x00\x00\x01"),
// batch end
[]byte("\x00\x00\x00\x0a"),
}
@ -1359,7 +1363,7 @@ func TestMakeConnmarkSaveExprs(t *testing.T) {
// nft add chain ip mangle OUTPUT { type route hook output priority mangle; }
[]byte("\x02\x00\x00\x00\x0b\x00\x01\x00\x6d\x61\x6e\x67\x6c\x65\x00\x00\x0b\x00\x03\x00\x4f\x55\x54\x50\x55\x54\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x03\x08\x00\x02\x00\xff\xff\xff\x6a\x0a\x00\x07\x00\x72\x6f\x75\x74\x65\x00\x00\x00"),
// nft add rule ip mangle OUTPUT ct state new meta mark & 0xff0000 != 0 ct mark set meta mark & 0xff0000
[]byte("\x02\x00\x00\x00\x0b\x00\x01\x00\x6d\x61\x6e\x67\x6c\x65\x00\x00\x0b\x00\x02\x00\x4f\x55\x54\x50\x55\x54\x00\x00\xb0\x01\x04\x80\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x00\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x08\x00\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\xff\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\xff\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x04\x00\x00\x00\x00\x01"),
[]byte("\x02\x00\x00\x00\x0b\x00\x01\x00\x6d\x61\x6e\x67\x6c\x65\x00\x00\x0b\x00\x02\x00\x4f\x55\x54\x50\x55\x54\x00\x00\xb0\x01\x04\x80\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x00\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x08\x00\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\x00\xff\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\x00\xff\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x20\x00\x01\x80\x07\x00\x01\x00\x63\x74\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x04\x00\x00\x00\x00\x01"),
// batch end
[]byte("\x00\x00\x00\x0a"),
}

View File

@ -76,6 +76,7 @@ const (
EnableIncomingConnections Key = "AllowIncomingConnections"
EnableServerMode Key = "UnattendedMode"
ExitNodeAllowLANAccess Key = "ExitNodeAllowLANAccess"
ExitNodeAllowWANPorts Key = "ExitNodeAllowWANPorts"
EnableTailscaleDNS Key = "UseTailscaleDNSSettings"
EnableTailscaleSubnets Key = "UseTailscaleSubnets"

View File

@ -32,6 +32,7 @@ var implicitDefinitions = []*setting.Definition{
setting.NewDefinition(pkey.EnableTailscaleDNS, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(pkey.EnableTailscaleSubnets, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(pkey.ExitNodeAllowLANAccess, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(pkey.ExitNodeAllowWANPorts, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(pkey.ExitNodeID, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(pkey.ExitNodeIP, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(pkey.FlushDNSOnSessionUnlock, setting.DeviceSetting, setting.BooleanValue),

View File

@ -12,6 +12,7 @@ import (
"net/netip"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"sync"
@ -27,6 +28,7 @@ import (
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/tsconst"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
@ -87,6 +89,7 @@ type linuxRouter struct {
snatSubnetRoutes bool
statefulFiltering bool
connmarkEnabled bool // whether connmark rules are currently enabled
wanBypassPorts []tailcfg.ProtoPortRange
netfilterMode preftype.NetfilterMode
netfilterKind string
cgnatMode linuxfw.CGNATMode
@ -373,6 +376,11 @@ func (r *linuxRouter) Close() error {
}
r.eventClient.Close()
// Clean up WAN bypass rules
if err := r.nfr.DelWANBypassRule(r.tunname); err != nil {
r.logf("warning: failed to delete WAN bypass rules: %v", err)
}
// Clean up connmark rules
if err := r.nfr.DelConnmarkSaveRule(); err != nil {
r.logf("warning: failed to delete connmark rules: %v", err)
@ -438,6 +446,29 @@ func (r *linuxRouter) Set(cfg *router.Config) error {
errs = append(errs, err)
}
// WAN bypass rules must be installed before routes so that existing
// connections on the specified ports get tagged before the exit node's
// default route captures their reply traffic.
isNetfilterOn := cfg.NetfilterMode == netfilterOn
wantWANBypass := len(cfg.ExitNodeAllowWANPorts) > 0 && isNetfilterOn
if wantWANBypass {
if !slices.Equal(cfg.ExitNodeAllowWANPorts, r.wanBypassPorts) {
r.logf("enabling WAN bypass for ports %v", cfg.ExitNodeAllowWANPorts)
if err := r.nfr.AddWANBypassRule(r.tunname, cfg.ExitNodeAllowWANPorts); err != nil {
r.logf("warning: failed to add WAN bypass rules: %v", err)
errs = append(errs, fmt.Errorf("enabling WAN bypass rules: %w", err))
} else {
r.wanBypassPorts = slices.Clone(cfg.ExitNodeAllowWANPorts)
}
}
} else if len(r.wanBypassPorts) > 0 {
r.logf("disabling WAN bypass for incoming connections")
if err := r.nfr.DelWANBypassRule(r.tunname); err != nil {
r.logf("warning: failed to delete WAN bypass rules: %v", err)
}
r.wanBypassPorts = nil
}
newLocalRoutes, err := cidrDiff("localRoute", r.localRoutes, cfg.LocalRoutes, r.addThrowRoute, r.delThrowRoute, r.logf)
if err != nil {
errs = append(errs, err)
@ -490,11 +521,10 @@ func (r *linuxRouter) Set(cfg *router.Config) error {
// 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:
case isNetfilterOn == r.connmarkEnabled:
// state already correct, nothing to do.
case netfilterOn:
case isNetfilterOn:
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)
@ -531,7 +561,7 @@ func (r *linuxRouter) Set(cfg *router.Config) error {
}
// Remove the rule to drop off-tailnet CGNAT traffic, if asked.
if netfilterOn || cfg.NetfilterMode == netfilterNoDivert {
if isNetfilterOn || cfg.NetfilterMode == netfilterNoDivert {
var cgnatMode linuxfw.CGNATMode
if cfg.RemoveCGNATDropRule {
cgnatMode = linuxfw.CGNATModeReturn

View File

@ -25,6 +25,7 @@ import (
"tailscale.com/health"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tsconst"
"tailscale.com/tstest"
"tailscale.com/types/logger"
@ -507,6 +508,46 @@ v6/mangle/OUTPUT -m conntrack --ctstate NEW -m mark ! --mark 0x0/0xff0000 -j CON
v6/mangle/PREROUTING -m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark --nfmask 0xff0000 --ctmask 0xff0000
v6/nat/POSTROUTING -j ts-postrouting
v6/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
`,
},
{
name: "wan-bypass-single-port",
in: &Config{
LocalAddrs: mustCIDRs("100.101.102.104/10"),
Routes: mustCIDRs("0.0.0.0/0", "::/0", "100.100.100.100/32"),
ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}},
NetfilterMode: netfilterOn,
},
want: `
up
ip addr add 100.101.102.104/10 dev tailscale0
ip route add 0.0.0.0/0 dev tailscale0 table 52
ip route add 100.100.100.100/32 dev tailscale0 table 52
ip route add ::/0 dev tailscale0 table 52` + basic +
`v4/filter/FORWARD -j ts-forward
v4/filter/INPUT -j ts-input
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v4/mangle/OUTPUT -m conntrack --ctstate NEW -m mark ! --mark 0x0/0xff0000 -j CONNMARK --save-mark --nfmask 0xff0000 --ctmask 0xff0000
v4/mangle/OUTPUT -p tcp --sport 443 -m connmark --mark 0x80000/0xff0000 -m conntrack --ctstate ESTABLISHED,RELATED -m comment --comment ts-wan-bypass -j MARK --set-mark 0x80000/0xff0000
v4/mangle/PREROUTING -m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark --nfmask 0xff0000 --ctmask 0xff0000
v4/mangle/PREROUTING ! -i tailscale0 -p tcp --dport 443 -m comment --comment ts-wan-bypass -j CONNMARK --set-mark 0x80000/0xff0000
v4/nat/POSTROUTING -j ts-postrouting
v6/filter/FORWARD -j ts-forward
v6/filter/INPUT -j ts-input
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/mangle/OUTPUT -m conntrack --ctstate NEW -m mark ! --mark 0x0/0xff0000 -j CONNMARK --save-mark --nfmask 0xff0000 --ctmask 0xff0000
v6/mangle/OUTPUT -p tcp --sport 443 -m connmark --mark 0x80000/0xff0000 -m conntrack --ctstate ESTABLISHED,RELATED -m comment --comment ts-wan-bypass -j MARK --set-mark 0x80000/0xff0000
v6/mangle/PREROUTING -m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark --nfmask 0xff0000 --ctmask 0xff0000
v6/mangle/PREROUTING ! -i tailscale0 -p tcp --dport 443 -m comment --comment ts-wan-bypass -j CONNMARK --set-mark 0x80000/0xff0000
v6/nat/POSTROUTING -j ts-postrouting
`,
},
}
@ -953,6 +994,59 @@ func (n *fakeIPTablesRunner) DelConnmarkSaveRule() error {
return nil
}
func (n *fakeIPTablesRunner) AddWANBypassRule(tunname string, ports []tailcfg.ProtoPortRange) error {
// Clear existing rules first.
n.DelWANBypassRule(tunname)
for _, ppr := range ports {
protos := []int{ppr.Proto}
if ppr.Proto == 0 {
protos = []int{6, 17}
}
pr := fmt.Sprintf("%d", ppr.Ports.First)
if ppr.Ports.First != ppr.Ports.Last {
pr = fmt.Sprintf("%d:%d", ppr.Ports.First, ppr.Ports.Last)
}
for _, proto := range protos {
pn := "tcp"
if proto == 17 {
pn = "udp"
}
prerouteRule := fmt.Sprintf("! -i %s -p %s --dport %s -m comment --comment ts-wan-bypass -j CONNMARK --set-mark 0x80000/0xff0000", tunname, pn, pr)
for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} {
if err := appendRule(n, ipt, "mangle/PREROUTING", prerouteRule); err != nil {
return err
}
}
outputRule := fmt.Sprintf("-p %s --sport %s -m connmark --mark 0x80000/0xff0000 -m conntrack --ctstate ESTABLISHED,RELATED -m comment --comment ts-wan-bypass -j MARK --set-mark 0x80000/0xff0000", pn, pr)
for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} {
if err := appendRule(n, ipt, "mangle/OUTPUT", outputRule); err != nil {
return err
}
}
}
}
return nil
}
func (n *fakeIPTablesRunner) DelWANBypassRule(tunname string) error {
for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} {
for _, chain := range []string{"mangle/PREROUTING", "mangle/OUTPUT"} {
rules := ipt[chain]
var kept []string
for _, rule := range rules {
if !strings.Contains(rule, "ts-wan-bypass") {
kept = append(kept, rule)
}
}
if len(kept) != len(rules) {
ipt[chain] = kept
}
}
}
return nil
}
func buildExternalCGNATRules(mode linuxfw.CGNATMode, tunname string) ([]iptRule, error) {
switch mode {
case linuxfw.CGNATModeDrop:

View File

@ -18,6 +18,7 @@ import (
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/preftype"
"tailscale.com/util/eventbus"
@ -132,11 +133,12 @@ type Config struct {
SubnetRoutes []netip.Prefix
// Linux-only things below, ignored on other platforms.
SNATSubnetRoutes bool // SNAT traffic to local subnets
StatefulFiltering bool // Apply stateful filtering to inbound connections
NetfilterMode preftype.NetfilterMode // how much to manage netfilter rules
NetfilterKind string // what kind of netfilter to use ("nftables", "iptables", or "" to auto-detect)
RemoveCGNATDropRule bool // whether to remove the firewall rule to drop non-Tailscale inbound traffic from CGNAT IPs
SNATSubnetRoutes bool // SNAT traffic to local subnets
StatefulFiltering bool // Apply stateful filtering to inbound connections
ExitNodeAllowWANPorts []tailcfg.ProtoPortRange // proto:port pairs that bypass exit node routing for incoming WAN connections
NetfilterMode preftype.NetfilterMode // how much to manage netfilter rules
NetfilterKind string // what kind of netfilter to use ("nftables", "iptables", or "" to auto-detect)
RemoveCGNATDropRule bool // whether to remove the firewall rule to drop non-Tailscale inbound traffic from CGNAT IPs
}
func (a *Config) Equal(b *Config) bool {
@ -158,5 +160,6 @@ func (c *Config) Clone() *Config {
c2.Routes = slices.Clone(c.Routes)
c2.LocalRoutes = slices.Clone(c.LocalRoutes)
c2.SubnetRoutes = slices.Clone(c.SubnetRoutes)
c2.ExitNodeAllowWANPorts = slices.Clone(c.ExitNodeAllowWANPorts)
return &c2
}

View File

@ -8,6 +8,7 @@ import (
"reflect"
"testing"
"tailscale.com/tailcfg"
"tailscale.com/types/preftype"
)
@ -15,7 +16,8 @@ func TestConfigEqual(t *testing.T) {
testedFields := []string{
"LocalAddrs", "Routes", "LocalRoutes", "NewMTU",
"SubnetRoutes", "SNATSubnetRoutes", "StatefulFiltering",
"NetfilterMode", "NetfilterKind", "RemoveCGNATDropRule",
"ExitNodeAllowWANPorts", "NetfilterMode", "NetfilterKind",
"RemoveCGNATDropRule",
}
configType := reflect.TypeFor[Config]()
configFields := []string{}
@ -147,6 +149,22 @@ func TestConfigEqual(t *testing.T) {
&Config{NewMTU: 0},
false,
},
{
&Config{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
&Config{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
true,
},
{
&Config{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
&Config{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 22, Last: 22}}}},
false,
},
{
&Config{ExitNodeAllowWANPorts: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}},
&Config{},
false,
},
}
for i, tt := range tests {
got := tt.a.Equal(tt.b)