Browse Source

util/linuxfw, feature/buildfeatures: add ts_omit_iptables to make IPTables optional

Updates #12614

Change-Id: Ic0eba982aa8468a55c63e1b763345f032a55b4e2
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 5 months ago
parent
commit
dd615c8fdd

+ 2 - 1
cmd/derper/depaware.txt

@@ -98,7 +98,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
         tailscale.com/disco                                          from tailscale.com/derp/derpserver
         tailscale.com/drive                                          from tailscale.com/client/local+
         tailscale.com/envknob                                        from tailscale.com/client/local+
-        tailscale.com/feature                                        from tailscale.com/tsweb
+        tailscale.com/feature                                        from tailscale.com/tsweb+
+   L    tailscale.com/feature/buildfeatures                          from tailscale.com/util/linuxfw
         tailscale.com/health                                         from tailscale.com/net/tlsdial+
         tailscale.com/hostinfo                                       from tailscale.com/net/netmon+
         tailscale.com/ipn                                            from tailscale.com/client/local

+ 2 - 3
cmd/tailscaled/depaware-minbox.txt

@@ -2,7 +2,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
 
         filippo.io/edwards25519                                      from github.com/hdevalence/ed25519consensus
         filippo.io/edwards25519/field                                from filippo.io/edwards25519
-        github.com/coreos/go-iptables/iptables                       from tailscale.com/util/linuxfw
         github.com/digitalocean/go-smbios/smbios                     from tailscale.com/posture
         github.com/gaissmai/bart                                     from tailscale.com/net/ipset+
         github.com/gaissmai/bart/internal/bitset                     from github.com/gaissmai/bart+
@@ -420,13 +419,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         net/textproto                                                from golang.org/x/net/http/httpguts+
         net/url                                                      from crypto/x509+
         os                                                           from crypto/internal/sysrand+
-        os/exec                                                      from github.com/coreos/go-iptables/iptables+
+        os/exec                                                      from tailscale.com/clientupdate+
         os/signal                                                    from tailscale.com/cmd/tailscaled
         os/user                                                      from archive/tar+
         path                                                         from archive/tar+
         path/filepath                                                from archive/tar+
         reflect                                                      from archive/tar+
-        regexp                                                       from github.com/coreos/go-iptables/iptables+
+        regexp                                                       from internal/profile+
         regexp/syntax                                                from regexp
         runtime                                                      from archive/tar+
         runtime/debug                                                from github.com/klauspost/compress/zstd+

+ 13 - 0
feature/buildfeatures/feature_iptables_disabled.go

@@ -0,0 +1,13 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Code generated by gen.go; DO NOT EDIT.
+
+//go:build ts_omit_iptables
+
+package buildfeatures
+
+// HasIPTables is whether the binary was built with support for modular feature "Linux iptables support".
+// Specifically, it's whether the binary was NOT built with the "ts_omit_iptables" build tag.
+// It's a const so it can be used for dead code elimination.
+const HasIPTables = false

+ 13 - 0
feature/buildfeatures/feature_iptables_enabled.go

@@ -0,0 +1,13 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Code generated by gen.go; DO NOT EDIT.
+
+//go:build !ts_omit_iptables
+
+package buildfeatures
+
+// HasIPTables is whether the binary was built with support for modular feature "Linux iptables support".
+// Specifically, it's whether the binary was NOT built with the "ts_omit_iptables" build tag.
+// It's a const so it can be used for dead code elimination.
+const HasIPTables = true

+ 1 - 0
feature/featuretags/featuretags.go

@@ -112,6 +112,7 @@ var Features = map[FeatureTag]FeatureMeta{
 		Desc: "Generic Receive Offload support (performance)",
 		Deps: []FeatureTag{"netstack"},
 	},
+	"iptables":      {"IPTables", "Linux iptables support", nil},
 	"kube":          {"Kube", "Kubernetes integration", nil},
 	"linuxdnsfight": {"LinuxDNSFight", "Linux support for detecting DNS fights (inotify watching of /etc/resolv.conf)", nil},
 	"oauthkey":      {"OAuthKey", "OAuth secret-to-authkey resolution support", nil},

+ 2 - 0
ipn/ipn_view.go

@@ -421,6 +421,8 @@ func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
 
 // NetfilterKind specifies what netfilter implementation to use.
 //
+// It can be "iptables", "nftables", or "" to auto-detect.
+//
 // Linux-only.
 func (v PrefsView) NetfilterKind() string { return v.ж.NetfilterKind }
 

+ 2 - 0
ipn/prefs.go

@@ -264,6 +264,8 @@ type Prefs struct {
 
 	// NetfilterKind specifies what netfilter implementation to use.
 	//
+	// It can be "iptables", "nftables", or "" to auto-detect.
+	//
 	// Linux-only.
 	NetfilterKind string
 

+ 30 - 7
util/linuxfw/detector.go

@@ -10,6 +10,8 @@ import (
 	"os/exec"
 
 	"tailscale.com/envknob"
+	"tailscale.com/feature"
+	"tailscale.com/feature/buildfeatures"
 	"tailscale.com/hostinfo"
 	"tailscale.com/types/logger"
 	"tailscale.com/version/distro"
@@ -42,10 +44,12 @@ func detectFirewallMode(logf logger.Logf, prefHint string) FirewallMode {
 	var det linuxFWDetector
 	if mode == "" {
 		// We have no preference, so check if `iptables` is even available.
-		_, err := det.iptDetect()
-		if err != nil && errors.Is(err, exec.ErrNotFound) {
-			logf("iptables not found: %v; falling back to nftables", err)
-			mode = "nftables"
+		if buildfeatures.HasIPTables {
+			_, err := det.iptDetect()
+			if err != nil && errors.Is(err, exec.ErrNotFound) {
+				logf("iptables not found: %v; falling back to nftables", err)
+				mode = "nftables"
+			}
 		}
 	}
 
@@ -59,11 +63,16 @@ func detectFirewallMode(logf logger.Logf, prefHint string) FirewallMode {
 		return FirewallModeNfTables
 	case "iptables":
 		hostinfo.SetFirewallMode("ipt-forced")
-	default:
+		return FirewallModeIPTables
+	}
+	if buildfeatures.HasIPTables {
 		logf("default choosing iptables")
 		hostinfo.SetFirewallMode("ipt-default")
+		return FirewallModeIPTables
 	}
-	return FirewallModeIPTables
+	logf("default choosing nftables")
+	hostinfo.SetFirewallMode("nft-default")
+	return FirewallModeNfTables
 }
 
 // tableDetector abstracts helpers to detect the firewall mode.
@@ -80,19 +89,33 @@ func (l linuxFWDetector) iptDetect() (int, error) {
 	return detectIptables()
 }
 
+var hookDetectNetfilter feature.Hook[func() (int, error)]
+
+// ErrUnsupported is the error returned from all functions on non-Linux
+// platforms.
+var ErrUnsupported = errors.New("linuxfw:unsupported")
+
 // nftDetect returns the number of nftables rules in the current namespace.
 func (l linuxFWDetector) nftDetect() (int, error) {
-	return detectNetfilter()
+	if f, ok := hookDetectNetfilter.GetOk(); ok {
+		return f()
+	}
+	return 0, ErrUnsupported
 }
 
 // pickFirewallModeFromInstalledRules returns the firewall mode to use based on
 // the environment and the system's capabilities.
 func pickFirewallModeFromInstalledRules(logf logger.Logf, det tableDetector) FirewallMode {
+	if !buildfeatures.HasIPTables {
+		hostinfo.SetFirewallMode("nft-noipt")
+		return FirewallModeNfTables
+	}
 	if distro.Get() == distro.Gokrazy {
 		// Reduce startup logging on gokrazy. There's no way to do iptables on
 		// gokrazy anyway.
 		return FirewallModeNfTables
 	}
+
 	iptAva, nftAva := true, true
 	iptRuleCount, err := det.iptDetect()
 	if err != nil {

+ 1 - 1
util/linuxfw/fake.go

@@ -128,7 +128,7 @@ func (n *fakeIPTables) DeleteChain(table, chain string) error {
 	}
 }
 
-func NewFakeIPTablesRunner() *iptablesRunner {
+func NewFakeIPTablesRunner() NetfilterRunner {
 	ipt4 := newFakeIPTables()
 	v6Available := false
 	var ipt6 iptablesInterface

+ 164 - 1
util/linuxfw/iptables.go

@@ -1,21 +1,34 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build linux && (arm64 || amd64) && !ts_omit_iptables
+
 // TODO(#8502): add support for more architectures
-//go:build linux && (arm64 || amd64)
 
 package linuxfw
 
 import (
+	"bytes"
+	"errors"
 	"fmt"
+	"os"
 	"os/exec"
 	"strings"
 	"unicode"
 
+	"github.com/coreos/go-iptables/iptables"
 	"tailscale.com/types/logger"
 	"tailscale.com/util/multierr"
+	"tailscale.com/version/distro"
 )
 
+func init() {
+	isNotExistError = func(err error) bool {
+		var e *iptables.Error
+		return errors.As(err, &e) && e.IsNotExist()
+	}
+}
+
 // DebugNetfilter prints debug information about iptables rules to the
 // provided log function.
 func DebugIptables(logf logger.Logf) error {
@@ -71,3 +84,153 @@ func detectIptables() (int, error) {
 	// return the count of non-default rules
 	return count, nil
 }
+
+// newIPTablesRunner constructs a NetfilterRunner that programs iptables rules.
+// If the underlying iptables library fails to initialize, that error is
+// returned. The runner probes for IPv6 support once at initialization time and
+// if not found, no IPv6 rules will be modified for the lifetime of the runner.
+func newIPTablesRunner(logf logger.Logf) (*iptablesRunner, error) {
+	ipt4, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
+	if err != nil {
+		return nil, err
+	}
+
+	supportsV6, supportsV6NAT, supportsV6Filter := false, false, false
+	v6err := CheckIPv6(logf)
+	ip6terr := checkIP6TablesExists()
+	var ipt6 *iptables.IPTables
+	switch {
+	case v6err != nil:
+		logf("disabling tunneled IPv6 due to system IPv6 config: %v", v6err)
+	case ip6terr != nil:
+		logf("disabling tunneled IPv6 due to missing ip6tables: %v", ip6terr)
+	default:
+		supportsV6 = true
+		ipt6, err = iptables.NewWithProtocol(iptables.ProtocolIPv6)
+		if err != nil {
+			return nil, err
+		}
+		supportsV6Filter = checkSupportsV6Filter(ipt6, logf)
+		supportsV6NAT = checkSupportsV6NAT(ipt6, logf)
+		logf("netfilter running in iptables mode v6 = %v, v6filter = %v, v6nat = %v", supportsV6, supportsV6Filter, supportsV6NAT)
+	}
+	return &iptablesRunner{
+		ipt4:              ipt4,
+		ipt6:              ipt6,
+		v6Available:       supportsV6,
+		v6NATAvailable:    supportsV6NAT,
+		v6FilterAvailable: supportsV6Filter}, nil
+}
+
+// checkSupportsV6Filter returns whether the system has a "filter" table in the
+// IPv6 tables. Some container environments such as GitHub codespaces have
+// limited local IPv6 support, and containers containing ip6tables, but do not
+// have kernel support for IPv6 filtering.
+// We will not set ip6tables rules in these instances.
+func checkSupportsV6Filter(ipt *iptables.IPTables, logf logger.Logf) bool {
+	if ipt == nil {
+		return false
+	}
+	_, filterListErr := ipt.ListChains("filter")
+	if filterListErr == nil {
+		return true
+	}
+	logf("ip6tables filtering is not supported on this host: %v", filterListErr)
+	return false
+}
+
+// checkSupportsV6NAT returns whether the system has a "nat" table in the
+// IPv6 netfilter stack.
+//
+// The nat table was added after the initial release of ipv6
+// netfilter, so some older distros ship a kernel that can't NAT IPv6
+// traffic.
+// ipt must be initialized for IPv6.
+func checkSupportsV6NAT(ipt *iptables.IPTables, logf logger.Logf) bool {
+	if ipt == nil || ipt.Proto() != iptables.ProtocolIPv6 {
+		return false
+	}
+	_, natListErr := ipt.ListChains("nat")
+	if natListErr == nil {
+		return true
+	}
+
+	// TODO (irbekrm): the following two checks were added before the check
+	// above that verifies that nat chains can be listed. It is a
+	// container-friendly check (see
+	// https://github.com/tailscale/tailscale/issues/11344), but also should
+	// be good enough on its own in other environments. If we never observe
+	// it falsely succeed, let's remove the other two checks.
+
+	bs, err := os.ReadFile("/proc/net/ip6_tables_names")
+	if err != nil {
+		return false
+	}
+	if bytes.Contains(bs, []byte("nat\n")) {
+		logf("[unexpected] listing nat chains failed, but /proc/net/ip6_tables_name reports a nat table existing")
+		return true
+	}
+	if exec.Command("modprobe", "ip6table_nat").Run() == nil {
+		logf("[unexpected] listing nat chains failed, but modprobe ip6table_nat succeeded")
+		return true
+	}
+	return false
+}
+
+func init() {
+	hookIPTablesCleanup.Set(ipTablesCleanUp)
+}
+
+// ipTablesCleanUp removes all Tailscale added iptables rules.
+// Any errors that occur are logged to the provided logf.
+func ipTablesCleanUp(logf logger.Logf) {
+	switch distro.Get() {
+	case distro.Gokrazy, distro.JetKVM:
+		// These use nftables and don't have the "iptables" command.
+		// Avoid log spam on cleanup. (#12277)
+		return
+	}
+	err := clearRules(iptables.ProtocolIPv4, logf)
+	if err != nil {
+		logf("linuxfw: clear iptables: %v", err)
+	}
+
+	err = clearRules(iptables.ProtocolIPv6, logf)
+	if err != nil {
+		logf("linuxfw: clear ip6tables: %v", err)
+	}
+}
+
+// clearRules clears all the iptables rules created by Tailscale
+// for the given protocol. If error occurs, it's logged but not returned.
+func clearRules(proto iptables.Protocol, logf logger.Logf) error {
+	ipt, err := iptables.NewWithProtocol(proto)
+	if err != nil {
+		return err
+	}
+
+	var errs []error
+
+	if err := delTSHook(ipt, "filter", "INPUT", logf); err != nil {
+		errs = append(errs, err)
+	}
+	if err := delTSHook(ipt, "filter", "FORWARD", logf); err != nil {
+		errs = append(errs, err)
+	}
+	if err := delTSHook(ipt, "nat", "POSTROUTING", logf); err != nil {
+		errs = append(errs, err)
+	}
+
+	if err := delChain(ipt, "filter", "ts-input"); err != nil {
+		errs = append(errs, err)
+	}
+	if err := delChain(ipt, "filter", "ts-forward"); err != nil {
+		errs = append(errs, err)
+	}
+
+	if err := delChain(ipt, "nat", "ts-postrouting"); err != nil {
+		errs = append(errs, err)
+	}
+
+	return multierr.New(errs...)
+}

+ 20 - 0
util/linuxfw/iptables_disabled.go

@@ -0,0 +1,20 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build (linux && !(arm64 || amd64)) || ts_omit_iptables
+
+package linuxfw
+
+import (
+	"errors"
+
+	"tailscale.com/types/logger"
+)
+
+func detectIptables() (int, error) {
+	return 0, nil
+}
+
+func newIPTablesRunner(logf logger.Logf) (*iptablesRunner, error) {
+	return nil, errors.New("iptables disabled in build")
+}

+ 9 - 5
util/linuxfw/iptables_for_svcs_test.go

@@ -10,6 +10,10 @@ import (
 	"testing"
 )
 
+func newFakeIPTablesRunner() *iptablesRunner {
+	return NewFakeIPTablesRunner().(*iptablesRunner)
+}
+
 func Test_iptablesRunner_EnsurePortMapRuleForSvc(t *testing.T) {
 	v4Addr := netip.MustParseAddr("10.0.0.4")
 	v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::701:b62a")
@@ -45,7 +49,7 @@ func Test_iptablesRunner_EnsurePortMapRuleForSvc(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			iptr := NewFakeIPTablesRunner()
+			iptr := newFakeIPTablesRunner()
 			table := iptr.getIPTByAddr(tt.targetIP)
 			for _, ruleset := range tt.precreateSvcRules {
 				mustPrecreatePortMapRule(t, ruleset, table)
@@ -103,7 +107,7 @@ func Test_iptablesRunner_DeletePortMapRuleForSvc(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			iptr := NewFakeIPTablesRunner()
+			iptr := newFakeIPTablesRunner()
 			table := iptr.getIPTByAddr(tt.targetIP)
 			for _, ruleset := range tt.precreateSvcRules {
 				mustPrecreatePortMapRule(t, ruleset, table)
@@ -127,7 +131,7 @@ func Test_iptablesRunner_DeleteSvc(t *testing.T) {
 	v4Addr := netip.MustParseAddr("10.0.0.4")
 	v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::701:b62a")
 	testPM := PortMap{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}
-	iptr := NewFakeIPTablesRunner()
+	iptr := newFakeIPTablesRunner()
 
 	// create two rules that will consitute svc1
 	s1R1 := argsForPortMapRule("svc1", "tailscale0", v4Addr, testPM)
@@ -189,7 +193,7 @@ func Test_iptablesRunner_EnsureDNATRuleForSvc(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			iptr := NewFakeIPTablesRunner()
+			iptr := newFakeIPTablesRunner()
 			table := iptr.getIPTByAddr(tt.targetIP)
 			for _, ruleset := range tt.precreateSvcRules {
 				mustPrecreateDNATRule(t, ruleset, table)
@@ -248,7 +252,7 @@ func Test_iptablesRunner_DeleteDNATRuleForSvc(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			iptr := NewFakeIPTablesRunner()
+			iptr := newFakeIPTablesRunner()
 			table := iptr.getIPTByAddr(tt.targetIP)
 			for _, ruleset := range tt.precreateSvcRules {
 				mustPrecreateDNATRule(t, ruleset, table)

+ 1 - 156
util/linuxfw/iptables_runner.go

@@ -6,31 +6,22 @@
 package linuxfw
 
 import (
-	"bytes"
-	"errors"
 	"fmt"
 	"log"
 	"net/netip"
-	"os"
 	"os/exec"
 	"slices"
 	"strconv"
 	"strings"
 
-	"github.com/coreos/go-iptables/iptables"
 	"tailscale.com/net/tsaddr"
 	"tailscale.com/types/logger"
-	"tailscale.com/util/multierr"
-	"tailscale.com/version/distro"
 )
 
 // isNotExistError needs to be overridden in tests that rely on distinguishing
 // this error, because we don't have a good way how to create a new
 // iptables.Error of that type.
-var isNotExistError = func(err error) bool {
-	var e *iptables.Error
-	return errors.As(err, &e) && e.IsNotExist()
-}
+var isNotExistError = func(err error) bool { return false }
 
 type iptablesInterface interface {
 	// Adding this interface for testing purposes so we can mock out
@@ -62,98 +53,6 @@ func checkIP6TablesExists() error {
 	return nil
 }
 
-// newIPTablesRunner constructs a NetfilterRunner that programs iptables rules.
-// If the underlying iptables library fails to initialize, that error is
-// returned. The runner probes for IPv6 support once at initialization time and
-// if not found, no IPv6 rules will be modified for the lifetime of the runner.
-func newIPTablesRunner(logf logger.Logf) (*iptablesRunner, error) {
-	ipt4, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
-	if err != nil {
-		return nil, err
-	}
-
-	supportsV6, supportsV6NAT, supportsV6Filter := false, false, false
-	v6err := CheckIPv6(logf)
-	ip6terr := checkIP6TablesExists()
-	var ipt6 *iptables.IPTables
-	switch {
-	case v6err != nil:
-		logf("disabling tunneled IPv6 due to system IPv6 config: %v", v6err)
-	case ip6terr != nil:
-		logf("disabling tunneled IPv6 due to missing ip6tables: %v", ip6terr)
-	default:
-		supportsV6 = true
-		ipt6, err = iptables.NewWithProtocol(iptables.ProtocolIPv6)
-		if err != nil {
-			return nil, err
-		}
-		supportsV6Filter = checkSupportsV6Filter(ipt6, logf)
-		supportsV6NAT = checkSupportsV6NAT(ipt6, logf)
-		logf("netfilter running in iptables mode v6 = %v, v6filter = %v, v6nat = %v", supportsV6, supportsV6Filter, supportsV6NAT)
-	}
-	return &iptablesRunner{
-		ipt4:              ipt4,
-		ipt6:              ipt6,
-		v6Available:       supportsV6,
-		v6NATAvailable:    supportsV6NAT,
-		v6FilterAvailable: supportsV6Filter}, nil
-}
-
-// checkSupportsV6Filter returns whether the system has a "filter" table in the
-// IPv6 tables. Some container environments such as GitHub codespaces have
-// limited local IPv6 support, and containers containing ip6tables, but do not
-// have kernel support for IPv6 filtering.
-// We will not set ip6tables rules in these instances.
-func checkSupportsV6Filter(ipt *iptables.IPTables, logf logger.Logf) bool {
-	if ipt == nil {
-		return false
-	}
-	_, filterListErr := ipt.ListChains("filter")
-	if filterListErr == nil {
-		return true
-	}
-	logf("ip6tables filtering is not supported on this host: %v", filterListErr)
-	return false
-}
-
-// checkSupportsV6NAT returns whether the system has a "nat" table in the
-// IPv6 netfilter stack.
-//
-// The nat table was added after the initial release of ipv6
-// netfilter, so some older distros ship a kernel that can't NAT IPv6
-// traffic.
-// ipt must be initialized for IPv6.
-func checkSupportsV6NAT(ipt *iptables.IPTables, logf logger.Logf) bool {
-	if ipt == nil || ipt.Proto() != iptables.ProtocolIPv6 {
-		return false
-	}
-	_, natListErr := ipt.ListChains("nat")
-	if natListErr == nil {
-		return true
-	}
-
-	// TODO (irbekrm): the following two checks were added before the check
-	// above that verifies that nat chains can be listed. It is a
-	// container-friendly check (see
-	// https://github.com/tailscale/tailscale/issues/11344), but also should
-	// be good enough on its own in other environments. If we never observe
-	// it falsely succeed, let's remove the other two checks.
-
-	bs, err := os.ReadFile("/proc/net/ip6_tables_names")
-	if err != nil {
-		return false
-	}
-	if bytes.Contains(bs, []byte("nat\n")) {
-		logf("[unexpected] listing nat chains failed, but /proc/net/ip6_tables_name reports a nat table existing")
-		return true
-	}
-	if exec.Command("modprobe", "ip6table_nat").Run() == nil {
-		logf("[unexpected] listing nat chains failed, but modprobe ip6table_nat succeeded")
-		return true
-	}
-	return false
-}
-
 // HasIPV6 reports true if the system supports IPv6.
 func (i *iptablesRunner) HasIPV6() bool {
 	return i.v6Available
@@ -685,26 +584,6 @@ func (i *iptablesRunner) DelMagicsockPortRule(port uint16, network string) error
 	return nil
 }
 
-// IPTablesCleanUp removes all Tailscale added iptables rules.
-// Any errors that occur are logged to the provided logf.
-func IPTablesCleanUp(logf logger.Logf) {
-	switch distro.Get() {
-	case distro.Gokrazy, distro.JetKVM:
-		// These use nftables and don't have the "iptables" command.
-		// Avoid log spam on cleanup. (#12277)
-		return
-	}
-	err := clearRules(iptables.ProtocolIPv4, logf)
-	if err != nil {
-		logf("linuxfw: clear iptables: %v", err)
-	}
-
-	err = clearRules(iptables.ProtocolIPv6, logf)
-	if err != nil {
-		logf("linuxfw: clear ip6tables: %v", err)
-	}
-}
-
 // delTSHook deletes hook in a chain that jumps to a ts-chain. If the hook does not
 // exist, it's a no-op since the desired state is already achieved but we log the
 // error because error code from the iptables module resists unwrapping.
@@ -733,40 +612,6 @@ func delChain(ipt iptablesInterface, table, chain string) error {
 	return nil
 }
 
-// clearRules clears all the iptables rules created by Tailscale
-// for the given protocol. If error occurs, it's logged but not returned.
-func clearRules(proto iptables.Protocol, logf logger.Logf) error {
-	ipt, err := iptables.NewWithProtocol(proto)
-	if err != nil {
-		return err
-	}
-
-	var errs []error
-
-	if err := delTSHook(ipt, "filter", "INPUT", logf); err != nil {
-		errs = append(errs, err)
-	}
-	if err := delTSHook(ipt, "filter", "FORWARD", logf); err != nil {
-		errs = append(errs, err)
-	}
-	if err := delTSHook(ipt, "nat", "POSTROUTING", logf); err != nil {
-		errs = append(errs, err)
-	}
-
-	if err := delChain(ipt, "filter", "ts-input"); err != nil {
-		errs = append(errs, err)
-	}
-	if err := delChain(ipt, "filter", "ts-forward"); err != nil {
-		errs = append(errs, err)
-	}
-
-	if err := delChain(ipt, "nat", "ts-postrouting"); err != nil {
-		errs = append(errs, err)
-	}
-
-	return multierr.New(errs...)
-}
-
 // argsFromPostRoutingRule accepts a rule as returned by iptables.List and, if it is a rule from POSTROUTING chain,
 // returns the args part, else returns the original rule.
 func argsFromPostRoutingRule(r string) string {

+ 6 - 6
util/linuxfw/iptables_runner_test.go

@@ -20,7 +20,7 @@ func init() {
 }
 
 func TestAddAndDeleteChains(t *testing.T) {
-	iptr := NewFakeIPTablesRunner()
+	iptr := newFakeIPTablesRunner()
 	err := iptr.AddChains()
 	if err != nil {
 		t.Fatal(err)
@@ -59,7 +59,7 @@ func TestAddAndDeleteChains(t *testing.T) {
 }
 
 func TestAddAndDeleteHooks(t *testing.T) {
-	iptr := NewFakeIPTablesRunner()
+	iptr := newFakeIPTablesRunner()
 	// don't need to test what happens if the chains don't exist, because
 	// this is handled by fake iptables, in realife iptables would return error.
 	if err := iptr.AddChains(); err != nil {
@@ -113,7 +113,7 @@ func TestAddAndDeleteHooks(t *testing.T) {
 }
 
 func TestAddAndDeleteBase(t *testing.T) {
-	iptr := NewFakeIPTablesRunner()
+	iptr := newFakeIPTablesRunner()
 	tunname := "tun0"
 	if err := iptr.AddChains(); err != nil {
 		t.Fatal(err)
@@ -176,7 +176,7 @@ func TestAddAndDeleteBase(t *testing.T) {
 }
 
 func TestAddAndDelLoopbackRule(t *testing.T) {
-	iptr := NewFakeIPTablesRunner()
+	iptr := newFakeIPTablesRunner()
 	// We don't need to test for malformed addresses, AddLoopbackRule
 	// takes in a netip.Addr, which is already valid.
 	fakeAddrV4 := netip.MustParseAddr("192.168.0.2")
@@ -247,7 +247,7 @@ func TestAddAndDelLoopbackRule(t *testing.T) {
 }
 
 func TestAddAndDelSNATRule(t *testing.T) {
-	iptr := NewFakeIPTablesRunner()
+	iptr := newFakeIPTablesRunner()
 
 	if err := iptr.AddChains(); err != nil {
 		t.Fatal(err)
@@ -292,7 +292,7 @@ func TestAddAndDelSNATRule(t *testing.T) {
 
 func TestEnsureSNATForDst_ipt(t *testing.T) {
 	ip1, ip2, ip3 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("100.77.77.77")
-	iptr := NewFakeIPTablesRunner()
+	iptr := newFakeIPTablesRunner()
 
 	// 1. A new rule gets added
 	mustCreateSNATRule_ipt(t, iptr, ip1, ip2)

+ 11 - 0
util/linuxfw/linuxfw.go

@@ -14,6 +14,7 @@ import (
 	"strings"
 
 	"github.com/tailscale/netlink"
+	"tailscale.com/feature"
 	"tailscale.com/types/logger"
 )
 
@@ -180,3 +181,13 @@ func CheckIPRuleSupportsV6(logf logger.Logf) error {
 	defer netlink.RuleDel(rule)
 	return netlink.RuleAdd(rule)
 }
+
+var hookIPTablesCleanup feature.Hook[func(logger.Logf)]
+
+// IPTablesCleanUp removes all Tailscale added iptables rules.
+// Any errors that occur are logged to the provided logf.
+func IPTablesCleanUp(logf logger.Logf) {
+	if f, ok := hookIPTablesCleanup.GetOk(); ok {
+		f(logf)
+	}
+}

+ 0 - 40
util/linuxfw/linuxfw_unsupported.go

@@ -1,40 +0,0 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-// NOTE: linux_{arm64, amd64} are the only two currently supported archs due to missing
-// support in upstream dependencies.
-
-// TODO(#8502): add support for more architectures
-//go:build linux && !(arm64 || amd64)
-
-package linuxfw
-
-import (
-	"errors"
-
-	"tailscale.com/types/logger"
-)
-
-// ErrUnsupported is the error returned from all functions on non-Linux
-// platforms.
-var ErrUnsupported = errors.New("linuxfw:unsupported")
-
-// DebugNetfilter is not supported on non-Linux platforms.
-func DebugNetfilter(logf logger.Logf) error {
-	return ErrUnsupported
-}
-
-// DetectNetfilter is not supported on non-Linux platforms.
-func detectNetfilter() (int, error) {
-	return 0, ErrUnsupported
-}
-
-// DebugIptables is not supported on non-Linux platforms.
-func debugIptables(logf logger.Logf) error {
-	return ErrUnsupported
-}
-
-// DetectIptables is not supported on non-Linux platforms.
-func detectIptables() (int, error) {
-	return 0, ErrUnsupported
-}

+ 4 - 0
util/linuxfw/nftables.go

@@ -103,6 +103,10 @@ func DebugNetfilter(logf logger.Logf) error {
 	return nil
 }
 
+func init() {
+	hookDetectNetfilter.Set(detectNetfilter)
+}
+
 // detectNetfilter returns the number of nftables rules present in the system.
 func detectNetfilter() (int, error) {
 	// Frist try creating a dummy postrouting chain. Emperically, we have

+ 1 - 1
wgengine/router/router.go

@@ -94,7 +94,7 @@ type Config struct {
 	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)
+	NetfilterKind     string                 // what kind of netfilter to use ("nftables", "iptables", or "" to auto-detect)
 }
 
 func (a *Config) Equal(b *Config) bool {