diff --git a/cmd/containerboot/main.go b/cmd/containerboot/main.go index 56bbd2874..18f32bcf1 100644 --- a/cmd/containerboot/main.go +++ b/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() { diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index 38df92fe0..4584af687 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/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) } diff --git a/control/controlknobs/controlknobs.go b/control/controlknobs/controlknobs.go index c0ee040b7..fddffde3e 100644 --- a/control/controlknobs/controlknobs.go +++ b/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(), } } diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index f2c738cdd..40cc44296 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -55,6 +55,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct { AutoUpdate AutoUpdatePrefs AppConnector AppConnectorPrefs PostureChecking bool + NetfilterKind string Persist *persist.Persist }{}) diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 45e2c07bb..18436867d 100644 --- a/ipn/ipn_view.go +++ b/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 }{}) diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 7bf6316d9..4668fe85d 100644 --- a/ipn/ipnlocal/c2n.go +++ b/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") diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 8dad79198..1fe42840b 100644 --- a/ipn/ipnlocal/local.go +++ b/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) diff --git a/ipn/prefs.go b/ipn/prefs.go index 897547889..bb9b193fa 100644 --- a/ipn/prefs.go +++ b/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 { diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 489749909..f3987d2bf 100644 --- a/ipn/prefs_test.go +++ b/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) diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 5d4e5598d..debfc1438 100644 --- a/tailcfg/tailcfg.go +++ b/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. diff --git a/util/linuxfw/detector.go b/util/linuxfw/detector.go index 17b47e2b5..ea9e6741f 100644 --- a/util/linuxfw/detector.go +++ b/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") diff --git a/util/linuxfw/nftables_runner.go b/util/linuxfw/nftables_runner.go index bc1eecd9c..5715c0412 100644 --- a/util/linuxfw/nftables_runner.go +++ b/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) diff --git a/wgengine/router/router.go b/wgengine/router/router.go index ecea521ad..5fde0e6c7 100644 --- a/wgengine/router/router.go +++ b/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 { diff --git a/wgengine/router/router_linux.go b/wgengine/router/router_linux.go index d308415a9..ddc0cee6a 100644 --- a/wgengine/router/router_linux.go +++ b/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 } diff --git a/wgengine/router/router_test.go b/wgengine/router/router_test.go index d06033ba8..9c02a1bb4 100644 --- a/wgengine/router/router_test.go +++ b/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{}