Browse Source

linuxfw,wgengine/route,ipn: add c2n and nodeattrs to control linux netfilter

Updates tailscale/corp#14029.

Signed-off-by: Naman Sood <[email protected]>
Naman Sood 2 years ago
parent
commit
0a59754eda

+ 1 - 1
cmd/containerboot/main.go

@@ -91,7 +91,7 @@ func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
 	if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
 		return linuxfw.NewFakeIPTablesRunner(), nil
 	}
-	return linuxfw.New(logf)
+	return linuxfw.New(logf, "")
 }
 
 func main() {

+ 4 - 0
cmd/tailscale/cli/cli_test.go

@@ -813,6 +813,10 @@ func TestPrefFlagMapping(t *testing.T) {
 		case "RunWebClient":
 			// TODO(tailscale/corp#14335): Currently behind a feature flag.
 			continue
+		case "NetfilterKind":
+			// Handled by TS_DEBUG_FIREWALL_MODE env var, we don't want to have
+			// a CLI flag for this. The Pref is used by c2n.
+			continue
 		}
 		t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
 	}

+ 14 - 0
control/controlknobs/controlknobs.go

@@ -56,6 +56,14 @@ type Knobs struct {
 	// SilentDisco is whether the node should suppress disco heartbeats to its
 	// peers.
 	SilentDisco atomic.Bool
+
+	// LinuxForceIPTables is whether the node should use iptables for Linux
+	// netfiltering, unless overridden by the user.
+	LinuxForceIPTables atomic.Bool
+
+	// LinuxForceNfTables is whether the node should use nftables for Linux
+	// netfiltering, unless overridden by the user.
+	LinuxForceNfTables atomic.Bool
 }
 
 // UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
@@ -79,6 +87,8 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
 		peerMTUEnable                 = has(tailcfg.NodeAttrPeerMTUEnable)
 		dnsForwarderDisableTCPRetries = has(tailcfg.NodeAttrDNSForwarderDisableTCPRetries)
 		silentDisco                   = has(tailcfg.NodeAttrSilentDisco)
+		forceIPTables                 = has(tailcfg.NodeAttrLinuxMustUseIPTables)
+		forceNfTables                 = has(tailcfg.NodeAttrLinuxMustUseNfTables)
 	)
 
 	if has(tailcfg.NodeAttrOneCGNATEnable) {
@@ -97,6 +107,8 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
 	k.PeerMTUEnable.Store(peerMTUEnable)
 	k.DisableDNSForwarderTCPRetries.Store(dnsForwarderDisableTCPRetries)
 	k.SilentDisco.Store(silentDisco)
+	k.LinuxForceIPTables.Store(forceIPTables)
+	k.LinuxForceNfTables.Store(forceNfTables)
 }
 
 // AsDebugJSON returns k as something that can be marshalled with json.Marshal
@@ -116,5 +128,7 @@ func (k *Knobs) AsDebugJSON() map[string]any {
 		"PeerMTUEnable":                 k.PeerMTUEnable.Load(),
 		"DisableDNSForwarderTCPRetries": k.DisableDNSForwarderTCPRetries.Load(),
 		"SilentDisco":                   k.SilentDisco.Load(),
+		"LinuxForceIPTables":            k.LinuxForceIPTables.Load(),
+		"LinuxForceNfTables":            k.LinuxForceNfTables.Load(),
 	}
 }

+ 1 - 0
ipn/ipn_clone.go

@@ -55,6 +55,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
 	AutoUpdate             AutoUpdatePrefs
 	AppConnector           AppConnectorPrefs
 	PostureChecking        bool
+	NetfilterKind          string
 	Persist                *persist.Persist
 }{})
 

+ 2 - 0
ipn/ipn_view.go

@@ -90,6 +90,7 @@ func (v PrefsView) ProfileName() string                   { return v.ж.ProfileN
 func (v PrefsView) AutoUpdate() AutoUpdatePrefs           { return v.ж.AutoUpdate }
 func (v PrefsView) AppConnector() AppConnectorPrefs       { return v.ж.AppConnector }
 func (v PrefsView) PostureChecking() bool                 { return v.ж.PostureChecking }
+func (v PrefsView) NetfilterKind() string                 { return v.ж.NetfilterKind }
 func (v PrefsView) Persist() persist.PersistView          { return v.ж.Persist.View() }
 
 // A compilation failure here means this code must be regenerated, with the command at the top of this file.
@@ -119,6 +120,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
 	AutoUpdate             AutoUpdatePrefs
 	AppConnector           AppConnectorPrefs
 	PostureChecking        bool
+	NetfilterKind          string
 	Persist                *persist.Persist
 }{})
 

+ 29 - 0
ipn/ipnlocal/c2n.go

@@ -69,6 +69,9 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
 
 	// App Connectors.
 	req("GET /appconnector/routes"): handleC2NAppConnectorDomainRoutesGet,
+
+	// Linux netfilter.
+	req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
 }
 
 type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
@@ -222,6 +225,32 @@ func handleC2NAppConnectorDomainRoutesGet(b *LocalBackend, w http.ResponseWriter
 	json.NewEncoder(w).Encode(res)
 }
 
+func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
+	b.logf("c2n: POST /netfilter-kind received")
+
+	if version.OS() != "linux" {
+		http.Error(w, "netfilter kind only settable on linux", http.StatusNotImplemented)
+	}
+
+	kind := r.FormValue("kind")
+	b.logf("c2n: switching netfilter to %s", kind)
+
+	_, err := b.EditPrefs(&ipn.MaskedPrefs{
+		NetfilterKindSet: true,
+		Prefs: ipn.Prefs{
+			NetfilterKind: kind,
+		},
+	})
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	b.authReconfig()
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
 func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
 	b.logf("c2n: GET /update received")
 

+ 20 - 0
ipn/ipnlocal/local.go

@@ -271,6 +271,9 @@ type LocalBackend struct {
 	currentUser         ipnauth.WindowsToken
 	selfUpdateProgress  []ipnstate.UpdateProgress
 	lastSelfUpdateState ipnstate.SelfUpdateStatus
+	// capForcedNetfilter is the netfilter that control instructs Linux clients
+	// to use, unless overridden locally.
+	capForcedNetfilter string
 
 	// ServeConfig fields. (also guarded by mu)
 	lastServeConfJSON   mem.RO              // last JSON that was parsed into serveConfig
@@ -3901,12 +3904,21 @@ func (b *LocalBackend) routerConfig(cfg *wgcfg.Config, prefs ipn.PrefsView, oneC
 		singleRouteThreshold = 1
 	}
 
+	netfilterKind := b.capForcedNetfilter
+	if prefs.NetfilterKind() != "" {
+		if b.capForcedNetfilter != "" {
+			b.logf("nodeattr netfilter preference %s overridden by c2n pref %s", b.capForcedNetfilter, prefs.NetfilterKind())
+		}
+		netfilterKind = prefs.NetfilterKind()
+	}
+
 	rs := &router.Config{
 		LocalAddrs:       unmapIPPrefixes(cfg.Addresses),
 		SubnetRoutes:     unmapIPPrefixes(prefs.AdvertiseRoutes().AsSlice()),
 		SNATSubnetRoutes: !prefs.NoSNAT(),
 		NetfilterMode:    prefs.NetfilterMode(),
 		Routes:           peerRoutes(b.logf, cfg.Peers, singleRouteThreshold),
+		NetfilterKind:    netfilterKind,
 	}
 
 	if distro.Get() == distro.Synology {
@@ -4416,6 +4428,14 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
 	}
 	b.capFileSharing = fs
 
+	if hasCapability(nm, tailcfg.NodeAttrLinuxMustUseIPTables) {
+		b.capForcedNetfilter = "iptables"
+	} else if hasCapability(nm, tailcfg.NodeAttrLinuxMustUseNfTables) {
+		b.capForcedNetfilter = "nftables"
+	} else {
+		b.capForcedNetfilter = "" // empty string means client can auto-detect
+	}
+
 	b.MagicConn().SetSilentDisco(b.ControlKnobs().SilentDisco.Load())
 
 	b.setDebugLogsByCapabilityLocked(nm)

+ 16 - 1
ipn/prefs.go

@@ -45,6 +45,8 @@ func IsLoginServerSynonym(val any) bool {
 }
 
 // Prefs are the user modifiable settings of the Tailscale node agent.
+// When you add a Pref to this struct, remember to add a corresponding
+// field in MaskedPrefs, and check your field for equality in Prefs.Equals().
 type Prefs struct {
 	// ControlURL is the URL of the control server to use.
 	//
@@ -213,6 +215,11 @@ type Prefs struct {
 	// posture checks.
 	PostureChecking bool
 
+	// NetfilterKind specifies what netfilter implementation to use.
+	//
+	// Linux-only.
+	NetfilterKind string
+
 	// The Persist field is named 'Config' in the file for backward
 	// compatibility with earlier versions.
 	// TODO(apenwarr): We should move this out of here, it's not a pref.
@@ -241,6 +248,9 @@ type AppConnectorPrefs struct {
 }
 
 // MaskedPrefs is a Prefs with an associated bitmask of which fields are set.
+// Make sure that the bool you add here maintains the same ordering of fields
+// as the Prefs struct, because the ApplyEdits() function below relies on this
+// ordering to be the same.
 type MaskedPrefs struct {
 	Prefs
 
@@ -269,6 +279,7 @@ type MaskedPrefs struct {
 	AutoUpdateSet             bool `json:",omitempty"`
 	AppConnectorSet           bool `json:",omitempty"`
 	PostureCheckingSet        bool `json:",omitempty"`
+	NetfilterKindSet          bool `json:",omitempty"`
 }
 
 // ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs
@@ -409,6 +420,9 @@ func (p *Prefs) pretty(goos string) string {
 	if p.OperatorUser != "" {
 		fmt.Fprintf(&sb, "op=%q ", p.OperatorUser)
 	}
+	if p.NetfilterKind != "" {
+		fmt.Fprintf(&sb, "netfilterKind=%s ", p.NetfilterKind)
+	}
 	sb.WriteString(p.AutoUpdate.Pretty())
 	sb.WriteString(p.AppConnector.Pretty())
 	if p.Persist != nil {
@@ -468,7 +482,8 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
 		p.ProfileName == p2.ProfileName &&
 		p.AutoUpdate == p2.AutoUpdate &&
 		p.AppConnector == p2.AppConnector &&
-		p.PostureChecking == p2.PostureChecking
+		p.PostureChecking == p2.PostureChecking &&
+		p.NetfilterKind == p2.NetfilterKind
 }
 
 func (au AutoUpdatePrefs) Pretty() string {

+ 25 - 0
ipn/prefs_test.go

@@ -60,6 +60,7 @@ func TestPrefsEqual(t *testing.T) {
 		"AutoUpdate",
 		"AppConnector",
 		"PostureChecking",
+		"NetfilterKind",
 		"Persist",
 	}
 	if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) {
@@ -327,6 +328,16 @@ func TestPrefsEqual(t *testing.T) {
 			&Prefs{PostureChecking: false},
 			false,
 		},
+		{
+			&Prefs{NetfilterKind: "iptables"},
+			&Prefs{NetfilterKind: "iptables"},
+			true,
+		},
+		{
+			&Prefs{NetfilterKind: "nftables"},
+			&Prefs{NetfilterKind: ""},
+			false,
+		},
 	}
 	for i, tt := range tests {
 		got := tt.a.Equals(tt.b)
@@ -545,6 +556,20 @@ func TestPrefsPretty(t *testing.T) {
 			"linux",
 			`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
 		},
+		{
+			Prefs{
+				NetfilterKind: "iptables",
+			},
+			"linux",
+			`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off netfilterKind=iptables update=off Persist=nil}`,
+		},
+		{
+			Prefs{
+				NetfilterKind: "",
+			},
+			"linux",
+			`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
+		},
 	}
 	for i, tt := range tests {
 		got := tt.p.pretty(tt.os)

+ 10 - 0
tailcfg/tailcfg.go

@@ -2171,6 +2171,16 @@ const (
 	// NodeAttrDNSForwarderDisableTCPRetries disables retrying truncated
 	// DNS queries over TCP if the response is truncated.
 	NodeAttrDNSForwarderDisableTCPRetries NodeCapability = "dns-forwarder-disable-tcp-retries"
+
+	// NodeAttrLinuxMustUseIPTables forces Linux clients to use iptables for
+	// netfilter management.
+	// This cannot be set simultaneously with NodeAttrLinuxMustUseNfTables.
+	NodeAttrLinuxMustUseIPTables NodeCapability = "linux-netfilter?v=iptables"
+
+	// NodeAttrLinuxMustUseNfTables forces Linux clients to use nftables for
+	// netfilter management.
+	// This cannot be set simultaneously with NodeAttrLinuxMustUseIPTables.
+	NodeAttrLinuxMustUseNfTables NodeCapability = "linux-netfilter?v=nftables"
 )
 
 // SetDNSRequest is a request to add a DNS record.

+ 11 - 5
util/linuxfw/detector.go

@@ -12,7 +12,7 @@ import (
 	"tailscale.com/version/distro"
 )
 
-func detectFirewallMode(logf logger.Logf) FirewallMode {
+func detectFirewallMode(logf logger.Logf, prefHint string) FirewallMode {
 	if distro.Get() == distro.Gokrazy {
 		// Reduce startup logging on gokrazy. There's no way to do iptables on
 		// gokrazy anyway.
@@ -21,18 +21,24 @@ func detectFirewallMode(logf logger.Logf) FirewallMode {
 		return FirewallModeNfTables
 	}
 
-	envMode := envknob.String("TS_DEBUG_FIREWALL_MODE")
+	mode := envknob.String("TS_DEBUG_FIREWALL_MODE")
+	// If the envknob isn't set, fall back to the pref suggested by c2n or
+	// nodeattrs.
+	if mode == "" {
+		mode = prefHint
+		logf("using firewall mode pref %s", prefHint)
+	} else if prefHint != "" {
+		logf("TS_DEBUG_FIREWALL_MODE set, overriding firewall mode from %s to %s", prefHint, mode)
+	}
 	// We now use iptables as default and have "auto" and "nftables" as
 	// options for people to test further.
-	switch envMode {
+	switch mode {
 	case "auto":
 		return pickFirewallModeFromInstalledRules(logf, linuxFWDetector{})
 	case "nftables":
-		logf("envknob TS_DEBUG_FIREWALL_MODE=nftables set")
 		hostinfo.SetFirewallMode("nft-forced")
 		return FirewallModeNfTables
 	case "iptables":
-		logf("envknob TS_DEBUG_FIREWALL_MODE=iptables set")
 		hostinfo.SetFirewallMode("ipt-forced")
 	default:
 		logf("default choosing iptables")

+ 7 - 4
util/linuxfw/nftables_runner.go

@@ -511,10 +511,13 @@ type NetfilterRunner interface {
 	ClampMSSToPMTU(tun string, addr netip.Addr) error
 }
 
-// New creates a NetfilterRunner using either nftables or iptables.
-// As nftables is still experimental, iptables will be used unless TS_DEBUG_USE_NETLINK_NFTABLES is set.
-func New(logf logger.Logf) (NetfilterRunner, error) {
-	mode := detectFirewallMode(logf)
+// New creates a NetfilterRunner, auto-detecting whether to use
+// nftables or iptables.
+// As nftables is still experimental, iptables will be used unless
+// either the TS_DEBUG_FIREWALL_MODE environment variable, or the prefHint
+// parameter, is set to one of "nftables" or "auto".
+func New(logf logger.Logf, prefHint string) (NetfilterRunner, error) {
+	mode := detectFirewallMode(logf, prefHint)
 	switch mode {
 	case FirewallModeIPTables:
 		return newIPTablesRunner(logf)

+ 1 - 0
wgengine/router/router.go

@@ -76,6 +76,7 @@ type Config struct {
 	SubnetRoutes     []netip.Prefix         // subnets being advertised to other Tailscale nodes
 	SNATSubnetRoutes bool                   // SNAT traffic to local subnets
 	NetfilterMode    preftype.NetfilterMode // how much to manage netfilter rules
+	NetfilterKind    string                 // what kind of netfilter to use (nftables, iptables)
 }
 
 func (a *Config) Equal(b *Config) bool {

+ 29 - 1
wgengine/router/router_linux.go

@@ -47,6 +47,7 @@ type linuxRouter struct {
 	localRoutes      map[netip.Prefix]bool
 	snatSubnetRoutes bool
 	netfilterMode    preftype.NetfilterMode
+	netfilterKind    string
 
 	// ruleRestorePending is whether a timer has been started to
 	// restore deleted ip rules.
@@ -326,6 +327,21 @@ func (r *linuxRouter) Close() error {
 	return nil
 }
 
+// setupNetfilter initializes the NetfilterRunner in r.nfr. It expects r.nfr
+// to be nil, or the current netfilter to be set to netfilterOff.
+// kind should be either a linuxfw.FirewallMode, or the empty string for auto.
+func (r *linuxRouter) setupNetfilter(kind string) error {
+	r.netfilterKind = kind
+
+	var err error
+	r.nfr, err = linuxfw.New(r.logf, r.netfilterKind)
+	if err != nil {
+		return fmt.Errorf("could not create new netfilter: %w", err)
+	}
+
+	return nil
+}
+
 // Set implements the Router interface.
 func (r *linuxRouter) Set(cfg *Config) error {
 	var errs []error
@@ -333,6 +349,18 @@ func (r *linuxRouter) Set(cfg *Config) error {
 		cfg = &shutdownConfig
 	}
 
+	if cfg.NetfilterKind != r.netfilterKind {
+		if err := r.setNetfilterMode(netfilterOff); err != nil {
+			err = fmt.Errorf("could not disable existing netfilter: %w", err)
+			errs = append(errs, err)
+		} else {
+			r.nfr = nil
+			if err := r.setupNetfilter(cfg.NetfilterKind); err != nil {
+				errs = append(errs, err)
+			}
+		}
+	}
+
 	if err := r.setNetfilterMode(cfg.NetfilterMode); err != nil {
 		errs = append(errs, err)
 	}
@@ -383,7 +411,7 @@ func (r *linuxRouter) setNetfilterMode(mode preftype.NetfilterMode) error {
 
 	if r.nfr == nil {
 		var err error
-		r.nfr, err = linuxfw.New(r.logf)
+		r.nfr, err = linuxfw.New(r.logf, r.netfilterKind)
 		if err != nil {
 			return err
 		}

+ 1 - 0
wgengine/router/router_test.go

@@ -23,6 +23,7 @@ func TestConfigEqual(t *testing.T) {
 	testedFields := []string{
 		"LocalAddrs", "Routes", "LocalRoutes", "NewMTU",
 		"SubnetRoutes", "SNATSubnetRoutes", "NetfilterMode",
+		"NetfilterKind",
 	}
 	configType := reflect.TypeOf(Config{})
 	configFields := []string{}