diff --git a/cmd/tailscale/cli/risks.go b/cmd/tailscale/cli/risks.go index 6f3ebf37b..0a1fe07b7 100644 --- a/cmd/tailscale/cli/risks.go +++ b/cmd/tailscale/cli/risks.go @@ -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 diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index 6fd4b09ad..f9b4f186e 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -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 { diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 419d55020..f4c5ad7f8 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -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") diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index e179438cd..6459c1d07 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -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 diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 4e9d46bda..5ac3b29a8 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -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 diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 891558a48..99b035477 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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 { diff --git a/ipn/prefs.go b/ipn/prefs.go index 9125df2c1..d5f79b6b8 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -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() && diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 31dd2c55a..230e6a866 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -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) diff --git a/util/linuxfw/fake_netfilter.go b/util/linuxfw/fake_netfilter.go index e9d853508..4fedbce4e 100644 --- a/util/linuxfw/fake_netfilter.go +++ b/util/linuxfw/fake_netfilter.go @@ -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 } diff --git a/util/linuxfw/iptables_runner.go b/util/linuxfw/iptables_runner.go index ace23a235..1f6e682d3 100644 --- a/util/linuxfw/iptables_runner.go +++ b/util/linuxfw/iptables_runner.go @@ -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 " 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 diff --git a/util/linuxfw/iptables_runner_test.go b/util/linuxfw/iptables_runner_test.go index b5a13fdba..da711ef4b 100644 --- a/util/linuxfw/iptables_runner_test.go +++ b/util/linuxfw/iptables_runner_test.go @@ -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" diff --git a/util/linuxfw/linuxfw.go b/util/linuxfw/linuxfw.go index ed130a2b1..3f111b2d5 100644 --- a/util/linuxfw/linuxfw.go +++ b/util/linuxfw/linuxfw.go @@ -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. diff --git a/util/linuxfw/nftables_runner.go b/util/linuxfw/nftables_runner.go index b31be8bae..5a866b54d 100644 --- a/util/linuxfw/nftables_runner.go +++ b/util/linuxfw/nftables_runner.go @@ -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 != meta l4proto th dport 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 th sport 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 diff --git a/util/linuxfw/nftables_runner_test.go b/util/linuxfw/nftables_runner_test.go index 65df7718e..8beeeb419 100644 --- a/util/linuxfw/nftables_runner_test.go +++ b/util/linuxfw/nftables_runner_test.go @@ -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"), } diff --git a/util/syspolicy/pkey/pkey.go b/util/syspolicy/pkey/pkey.go index 9ed1d5b21..c54b0c18c 100644 --- a/util/syspolicy/pkey/pkey.go +++ b/util/syspolicy/pkey/pkey.go @@ -76,6 +76,7 @@ const ( EnableIncomingConnections Key = "AllowIncomingConnections" EnableServerMode Key = "UnattendedMode" ExitNodeAllowLANAccess Key = "ExitNodeAllowLANAccess" + ExitNodeAllowWANPorts Key = "ExitNodeAllowWANPorts" EnableTailscaleDNS Key = "UseTailscaleDNSSettings" EnableTailscaleSubnets Key = "UseTailscaleSubnets" diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go index 2a4599cb8..36796b93b 100644 --- a/util/syspolicy/policy_keys.go +++ b/util/syspolicy/policy_keys.go @@ -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), diff --git a/wgengine/router/osrouter/router_linux.go b/wgengine/router/osrouter/router_linux.go index 41a2a7b90..7dbd2d335 100644 --- a/wgengine/router/osrouter/router_linux.go +++ b/wgengine/router/osrouter/router_linux.go @@ -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 diff --git a/wgengine/router/osrouter/router_linux_test.go b/wgengine/router/osrouter/router_linux_test.go index 8830822e0..2f870445c 100644 --- a/wgengine/router/osrouter/router_linux_test.go +++ b/wgengine/router/osrouter/router_linux_test.go @@ -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: diff --git a/wgengine/router/router.go b/wgengine/router/router.go index f8d702d47..edc10c8e7 100644 --- a/wgengine/router/router.go +++ b/wgengine/router/router.go @@ -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 } diff --git a/wgengine/router/router_test.go b/wgengine/router/router_test.go index e6b415586..2a62ab710 100644 --- a/wgengine/router/router_test.go +++ b/wgengine/router/router_test.go @@ -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)