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

Updates tailscale/corp#14029.

Signed-off-by: Naman Sood <mail@nsood.in>
pull/10370/head
Naman Sood 5 months ago
parent 215f657a5e
commit 0a59754eda

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

@ -813,6 +813,10 @@ func TestPrefFlagMapping(t *testing.T) {
case "RunWebClient": case "RunWebClient":
// TODO(tailscale/corp#14335): Currently behind a feature flag. // TODO(tailscale/corp#14335): Currently behind a feature flag.
continue 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) t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
} }

@ -56,6 +56,14 @@ type Knobs struct {
// SilentDisco is whether the node should suppress disco heartbeats to its // SilentDisco is whether the node should suppress disco heartbeats to its
// peers. // peers.
SilentDisco atomic.Bool 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 // 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) peerMTUEnable = has(tailcfg.NodeAttrPeerMTUEnable)
dnsForwarderDisableTCPRetries = has(tailcfg.NodeAttrDNSForwarderDisableTCPRetries) dnsForwarderDisableTCPRetries = has(tailcfg.NodeAttrDNSForwarderDisableTCPRetries)
silentDisco = has(tailcfg.NodeAttrSilentDisco) silentDisco = has(tailcfg.NodeAttrSilentDisco)
forceIPTables = has(tailcfg.NodeAttrLinuxMustUseIPTables)
forceNfTables = has(tailcfg.NodeAttrLinuxMustUseNfTables)
) )
if has(tailcfg.NodeAttrOneCGNATEnable) { if has(tailcfg.NodeAttrOneCGNATEnable) {
@ -97,6 +107,8 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
k.PeerMTUEnable.Store(peerMTUEnable) k.PeerMTUEnable.Store(peerMTUEnable)
k.DisableDNSForwarderTCPRetries.Store(dnsForwarderDisableTCPRetries) k.DisableDNSForwarderTCPRetries.Store(dnsForwarderDisableTCPRetries)
k.SilentDisco.Store(silentDisco) k.SilentDisco.Store(silentDisco)
k.LinuxForceIPTables.Store(forceIPTables)
k.LinuxForceNfTables.Store(forceNfTables)
} }
// AsDebugJSON returns k as something that can be marshalled with json.Marshal // 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(), "PeerMTUEnable": k.PeerMTUEnable.Load(),
"DisableDNSForwarderTCPRetries": k.DisableDNSForwarderTCPRetries.Load(), "DisableDNSForwarderTCPRetries": k.DisableDNSForwarderTCPRetries.Load(),
"SilentDisco": k.SilentDisco.Load(), "SilentDisco": k.SilentDisco.Load(),
"LinuxForceIPTables": k.LinuxForceIPTables.Load(),
"LinuxForceNfTables": k.LinuxForceNfTables.Load(),
} }
} }

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

@ -90,6 +90,7 @@ func (v PrefsView) ProfileName() string { return v.ж.ProfileN
func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate } func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate }
func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector } func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector }
func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking } 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() } 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. // 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 AutoUpdate AutoUpdatePrefs
AppConnector AppConnectorPrefs AppConnector AppConnectorPrefs
PostureChecking bool PostureChecking bool
NetfilterKind string
Persist *persist.Persist Persist *persist.Persist
}{}) }{})

@ -69,6 +69,9 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
// App Connectors. // App Connectors.
req("GET /appconnector/routes"): handleC2NAppConnectorDomainRoutesGet, req("GET /appconnector/routes"): handleC2NAppConnectorDomainRoutesGet,
// Linux netfilter.
req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
} }
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request) type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
@ -222,6 +225,32 @@ func handleC2NAppConnectorDomainRoutesGet(b *LocalBackend, w http.ResponseWriter
json.NewEncoder(w).Encode(res) 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) { func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received") b.logf("c2n: GET /update received")

@ -271,6 +271,9 @@ type LocalBackend struct {
currentUser ipnauth.WindowsToken currentUser ipnauth.WindowsToken
selfUpdateProgress []ipnstate.UpdateProgress selfUpdateProgress []ipnstate.UpdateProgress
lastSelfUpdateState ipnstate.SelfUpdateStatus 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) // ServeConfig fields. (also guarded by mu)
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig 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 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{ rs := &router.Config{
LocalAddrs: unmapIPPrefixes(cfg.Addresses), LocalAddrs: unmapIPPrefixes(cfg.Addresses),
SubnetRoutes: unmapIPPrefixes(prefs.AdvertiseRoutes().AsSlice()), SubnetRoutes: unmapIPPrefixes(prefs.AdvertiseRoutes().AsSlice()),
SNATSubnetRoutes: !prefs.NoSNAT(), SNATSubnetRoutes: !prefs.NoSNAT(),
NetfilterMode: prefs.NetfilterMode(), NetfilterMode: prefs.NetfilterMode(),
Routes: peerRoutes(b.logf, cfg.Peers, singleRouteThreshold), Routes: peerRoutes(b.logf, cfg.Peers, singleRouteThreshold),
NetfilterKind: netfilterKind,
} }
if distro.Get() == distro.Synology { if distro.Get() == distro.Synology {
@ -4416,6 +4428,14 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
} }
b.capFileSharing = fs 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.MagicConn().SetSilentDisco(b.ControlKnobs().SilentDisco.Load())
b.setDebugLogsByCapabilityLocked(nm) b.setDebugLogsByCapabilityLocked(nm)

@ -45,6 +45,8 @@ func IsLoginServerSynonym(val any) bool {
} }
// Prefs are the user modifiable settings of the Tailscale node agent. // 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 { type Prefs struct {
// ControlURL is the URL of the control server to use. // ControlURL is the URL of the control server to use.
// //
@ -213,6 +215,11 @@ type Prefs struct {
// posture checks. // posture checks.
PostureChecking bool PostureChecking bool
// NetfilterKind specifies what netfilter implementation to use.
//
// Linux-only.
NetfilterKind string
// The Persist field is named 'Config' in the file for backward // The Persist field is named 'Config' in the file for backward
// compatibility with earlier versions. // compatibility with earlier versions.
// TODO(apenwarr): We should move this out of here, it's not a pref. // 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. // 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 { type MaskedPrefs struct {
Prefs Prefs
@ -269,6 +279,7 @@ type MaskedPrefs struct {
AutoUpdateSet bool `json:",omitempty"` AutoUpdateSet bool `json:",omitempty"`
AppConnectorSet bool `json:",omitempty"` AppConnectorSet bool `json:",omitempty"`
PostureCheckingSet bool `json:",omitempty"` PostureCheckingSet bool `json:",omitempty"`
NetfilterKindSet bool `json:",omitempty"`
} }
// ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs // 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 != "" { if p.OperatorUser != "" {
fmt.Fprintf(&sb, "op=%q ", 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.AutoUpdate.Pretty())
sb.WriteString(p.AppConnector.Pretty()) sb.WriteString(p.AppConnector.Pretty())
if p.Persist != nil { if p.Persist != nil {
@ -468,7 +482,8 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.ProfileName == p2.ProfileName && p.ProfileName == p2.ProfileName &&
p.AutoUpdate == p2.AutoUpdate && p.AutoUpdate == p2.AutoUpdate &&
p.AppConnector == p2.AppConnector && p.AppConnector == p2.AppConnector &&
p.PostureChecking == p2.PostureChecking p.PostureChecking == p2.PostureChecking &&
p.NetfilterKind == p2.NetfilterKind
} }
func (au AutoUpdatePrefs) Pretty() string { func (au AutoUpdatePrefs) Pretty() string {

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

@ -2171,6 +2171,16 @@ const (
// NodeAttrDNSForwarderDisableTCPRetries disables retrying truncated // NodeAttrDNSForwarderDisableTCPRetries disables retrying truncated
// DNS queries over TCP if the response is truncated. // DNS queries over TCP if the response is truncated.
NodeAttrDNSForwarderDisableTCPRetries NodeCapability = "dns-forwarder-disable-tcp-retries" 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. // SetDNSRequest is a request to add a DNS record.

@ -12,7 +12,7 @@ import (
"tailscale.com/version/distro" "tailscale.com/version/distro"
) )
func detectFirewallMode(logf logger.Logf) FirewallMode { func detectFirewallMode(logf logger.Logf, prefHint string) FirewallMode {
if distro.Get() == distro.Gokrazy { if distro.Get() == distro.Gokrazy {
// Reduce startup logging on gokrazy. There's no way to do iptables on // Reduce startup logging on gokrazy. There's no way to do iptables on
// gokrazy anyway. // gokrazy anyway.
@ -21,18 +21,24 @@ func detectFirewallMode(logf logger.Logf) FirewallMode {
return FirewallModeNfTables 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 // We now use iptables as default and have "auto" and "nftables" as
// options for people to test further. // options for people to test further.
switch envMode { switch mode {
case "auto": case "auto":
return pickFirewallModeFromInstalledRules(logf, linuxFWDetector{}) return pickFirewallModeFromInstalledRules(logf, linuxFWDetector{})
case "nftables": case "nftables":
logf("envknob TS_DEBUG_FIREWALL_MODE=nftables set")
hostinfo.SetFirewallMode("nft-forced") hostinfo.SetFirewallMode("nft-forced")
return FirewallModeNfTables return FirewallModeNfTables
case "iptables": case "iptables":
logf("envknob TS_DEBUG_FIREWALL_MODE=iptables set")
hostinfo.SetFirewallMode("ipt-forced") hostinfo.SetFirewallMode("ipt-forced")
default: default:
logf("default choosing iptables") logf("default choosing iptables")

@ -511,10 +511,13 @@ type NetfilterRunner interface {
ClampMSSToPMTU(tun string, addr netip.Addr) error ClampMSSToPMTU(tun string, addr netip.Addr) error
} }
// New creates a NetfilterRunner using either nftables or iptables. // New creates a NetfilterRunner, auto-detecting whether to use
// As nftables is still experimental, iptables will be used unless TS_DEBUG_USE_NETLINK_NFTABLES is set. // nftables or iptables.
func New(logf logger.Logf) (NetfilterRunner, error) { // As nftables is still experimental, iptables will be used unless
mode := detectFirewallMode(logf) // 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 { switch mode {
case FirewallModeIPTables: case FirewallModeIPTables:
return newIPTablesRunner(logf) return newIPTablesRunner(logf)

@ -76,6 +76,7 @@ type Config struct {
SubnetRoutes []netip.Prefix // subnets being advertised to other Tailscale nodes SubnetRoutes []netip.Prefix // subnets being advertised to other Tailscale nodes
SNATSubnetRoutes bool // SNAT traffic to local subnets SNATSubnetRoutes bool // SNAT traffic to local subnets
NetfilterMode preftype.NetfilterMode // how much to manage netfilter rules 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 { func (a *Config) Equal(b *Config) bool {

@ -47,6 +47,7 @@ type linuxRouter struct {
localRoutes map[netip.Prefix]bool localRoutes map[netip.Prefix]bool
snatSubnetRoutes bool snatSubnetRoutes bool
netfilterMode preftype.NetfilterMode netfilterMode preftype.NetfilterMode
netfilterKind string
// ruleRestorePending is whether a timer has been started to // ruleRestorePending is whether a timer has been started to
// restore deleted ip rules. // restore deleted ip rules.
@ -326,6 +327,21 @@ func (r *linuxRouter) Close() error {
return nil 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. // Set implements the Router interface.
func (r *linuxRouter) Set(cfg *Config) error { func (r *linuxRouter) Set(cfg *Config) error {
var errs []error var errs []error
@ -333,6 +349,18 @@ func (r *linuxRouter) Set(cfg *Config) error {
cfg = &shutdownConfig 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 { if err := r.setNetfilterMode(cfg.NetfilterMode); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
@ -383,7 +411,7 @@ func (r *linuxRouter) setNetfilterMode(mode preftype.NetfilterMode) error {
if r.nfr == nil { if r.nfr == nil {
var err error var err error
r.nfr, err = linuxfw.New(r.logf) r.nfr, err = linuxfw.New(r.logf, r.netfilterKind)
if err != nil { if err != nil {
return err return err
} }

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

Loading…
Cancel
Save