various: implement stateful firewalling on Linux (#12025)

Updates https://github.com/tailscale/corp/issues/19623


Change-Id: I7980e1fb736e234e66fa000d488066466c96ec85

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Co-authored-by: Andrew Dunham <andrew@du.nham.ca>
pull/12024/head
Andrew Lytvynov 7 months ago committed by GitHub
parent 5ef178fdca
commit c28f5767bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -658,6 +658,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
NoSNAT: false,
NoStatefulFiltering: "false",
NetfilterMode: preftype.NetfilterOn,
CorpDNS: true,
AllowSingleHosts: true,
@ -694,6 +695,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
NoStatefulFiltering: "false",
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
@ -784,6 +786,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
WantRunning: true,
NetfilterMode: preftype.NetfilterNoDivert,
NoSNAT: true,
NoStatefulFiltering: "true",
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
},
@ -800,6 +803,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
WantRunning: true,
NetfilterMode: preftype.NetfilterOff,
NoSNAT: true,
NoStatefulFiltering: "true",
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
},
@ -815,6 +819,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
want: &ipn.Prefs{
WantRunning: true,
NoSNAT: true,
NoStatefulFiltering: "true",
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
},
@ -833,6 +838,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
want: &ipn.Prefs{
WantRunning: true,
NoSNAT: true,
NoStatefulFiltering: "true",
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::aabb:10.0.0.0/112"),
},
@ -1031,6 +1037,7 @@ func TestUpdatePrefs(t *testing.T) {
HostnameSet: true,
NetfilterModeSet: true,
NoSNATSet: true,
NoStatefulFilteringSet: true,
OperatorUserSet: true,
RouteAllSet: true,
RunSSHSet: true,

@ -121,6 +121,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
switch goos {
case "linux":
upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes")
upf.BoolVar(&upArgs.statefulFiltering, "stateful-filtering", true, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, etc.)")
upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)")
case "windows":
upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)")
@ -168,6 +169,7 @@ type upArgsT struct {
advertiseTags string
advertiseConnector bool
snat bool
statefulFiltering bool
netfilterMode string
authKeyOrFile string // "secret" or "file:/path/to/secret"
hostname string
@ -291,6 +293,9 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
if goos == "linux" {
prefs.NoSNAT = !upArgs.snat
// Backfills for NoStatefulFiltering occur when loading a profile; just set it explicitly here.
prefs.NoStatefulFiltering.Set(!upArgs.statefulFiltering)
switch upArgs.netfilterMode {
case "on":
prefs.NetfilterMode = preftype.NetfilterOn
@ -711,6 +716,7 @@ func init() {
addPrefFlagMapping("netfilter-mode", "NetfilterMode")
addPrefFlagMapping("shields-up", "ShieldsUp")
addPrefFlagMapping("snat-subnet-routes", "NoSNAT")
addPrefFlagMapping("stateful-filtering", "NoStatefulFiltering")
addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess")
addPrefFlagMapping("unattended", "ForceDaemon")
addPrefFlagMapping("operator", "OperatorUser")
@ -895,7 +901,7 @@ func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, env upCheckEnv) {
func flagAppliesToOS(flag, goos string) bool {
switch flag {
case "netfilter-mode", "snat-subnet-routes":
case "netfilter-mode", "snat-subnet-routes", "stateful-filtering":
return goos == "linux"
case "unattended":
return goos == "windows"
@ -970,6 +976,16 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
set(prefs.AppConnector.Advertise)
case "snat-subnet-routes":
set(!prefs.NoSNAT)
case "stateful-filtering":
// We only set the stateful-filtering flag to false if
// the pref (negated!) is explicitly set to true; unset
// or false is treated as enabled.
val, ok := prefs.NoStatefulFiltering.Get()
if ok && val {
set(false)
} else {
set(true)
}
case "netfilter-mode":
set(prefs.NetfilterMode.String())
case "unattended":

@ -11,6 +11,7 @@ import (
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
)
@ -57,6 +58,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
Egg bool
AdvertiseRoutes []netip.Prefix
NoSNAT bool
NoStatefulFiltering opt.Bool
NetfilterMode preftype.NetfilterMode
OperatorUser string
ProfileName string

@ -12,6 +12,7 @@ import (
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
"tailscale.com/types/views"
@ -86,6 +87,7 @@ func (v PrefsView) AdvertiseRoutes() views.Slice[netip.Prefix] {
return views.SliceOf(v.ж.AdvertiseRoutes)
}
func (v PrefsView) NoSNAT() bool { return v.ж.NoSNAT }
func (v PrefsView) NoStatefulFiltering() opt.Bool { return v.ж.NoStatefulFiltering }
func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.NetfilterMode }
func (v PrefsView) OperatorUser() string { return v.ж.OperatorUser }
func (v PrefsView) ProfileName() string { return v.ж.ProfileName }
@ -120,6 +122,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
Egg bool
AdvertiseRoutes []netip.Prefix
NoSNAT bool
NoStatefulFiltering opt.Bool
NetfilterMode preftype.NetfilterMode
OperatorUser string
ProfileName string

@ -4153,10 +4153,30 @@ func (b *LocalBackend) routerConfig(cfg *wgcfg.Config, prefs ipn.PrefsView, oneC
netfilterKind = prefs.NetfilterKind()
}
var doStatefulFiltering bool
if v, ok := prefs.NoStatefulFiltering().Get(); !ok {
// The stateful filtering preference isn't explicitly set; this is
// unexpected since we expect it to be set during the profile
// backfill, but to be safe let's enable stateful filtering
// absent further information.
doStatefulFiltering = true
b.logf("[unexpected] NoStatefulFiltering preference not set; enabling stateful filtering")
} else if v {
// The preferences explicitly say "no stateful filtering", so
// we don't do it.
doStatefulFiltering = false
} else {
// The preferences explicitly "do stateful filtering" is turned
// off, or to expand the double negative, to do stateful
// filtering. Do so.
doStatefulFiltering = true
}
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),
NetfilterKind: netfilterKind,

@ -371,12 +371,39 @@ func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error
// https://github.com/tailscale/tailscale/pull/11814/commits/1613b18f8280c2bce786980532d012c9f0454fa2#diff-314ba0d799f70c8998940903efb541e511f352b39a9eeeae8d475c921d66c2ac
// prefs could set AutoUpdate.Apply=true via EditPrefs or tailnet
// auto-update defaults. After that change, such value is "invalid" and
// cause any EditPrefs calls to fail (other than disabling autp-updates).
// cause any EditPrefs calls to fail (other than disabling auto-updates).
//
// Reset AutoUpdate.Apply if we detect such invalid prefs.
if savedPrefs.AutoUpdate.Apply.EqualBool(true) && !clientupdate.CanAutoUpdate() {
savedPrefs.AutoUpdate.Apply.Clear()
}
// Backfill a missing NoStatefulFiltering field based on the value of
// the NoSNAT field; we want to apply stateful filtering in all cases
// *except* where the user has disabled SNAT.
//
// Only backfill if the user hasn't set a value for
// NoStatefulFiltering, however.
_, haveNoStateful := savedPrefs.NoStatefulFiltering.Get()
if !haveNoStateful {
if savedPrefs.NoSNAT {
pm.logf("backfilling NoStatefulFiltering field to true because NoSNAT is set")
// No SNAT: no stateful filtering
savedPrefs.NoStatefulFiltering.Set(true)
} else {
pm.logf("backfilling NoStatefulFiltering field to false because NoSNAT is not set")
// SNAT (default): apply stateful filtering
savedPrefs.NoStatefulFiltering.Set(false)
}
// Write back to the preferences store now that we've updated it.
if err := pm.writePrefsToStore(key, savedPrefs.View()); err != nil {
return ipn.PrefsView{}, err
}
}
return savedPrefs.View(), nil
}
@ -468,6 +495,7 @@ var defaultPrefs = func() ipn.PrefsView {
prefs := ipn.NewPrefs()
prefs.LoggedOut = true
prefs.WantRunning = false
prefs.NoStatefulFiltering = "false"
return prefs.View()
}()

@ -4,6 +4,7 @@
package ipnlocal
import (
"encoding/json"
"fmt"
"os/user"
"strconv"
@ -12,12 +13,14 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/util/must"
)
@ -600,3 +603,86 @@ func TestProfileManagementWindows(t *testing.T) {
t.Fatalf("CurrentUserID = %q; want %q", pm.CurrentUserID(), uid)
}
}
func TestProfileBackfillStatefulFiltering(t *testing.T) {
envknob.Setenv("TS_DEBUG_PROFILES", "true")
tests := []struct {
noSNAT bool
noStateful opt.Bool
want bool
}{
// Default: NoSNAT is false, NoStatefulFiltering is false, so
// we want it to stay false.
{false, "false", false},
// NoSNAT being set to true and NoStatefulFiltering being false
// should result in NoStatefulFiltering still being false,
// since it was explicitly set.
{true, "false", false},
// If NoSNAT is false, and NoStatefulFiltering is unset, we
// backfill it to 'false'.
{false, "", false},
// If NoSNAT is true, and NoStatefulFiltering is unset, we
// backfill to 'true' to not break users of NoSNAT.
//
// In other words: if the user is not using SNAT, they almost
// certainly also don't want to use stateful filtering.
{true, "", true},
// However, if the user specifies both NoSNAT and stateful
// filtering, don't change that.
{true, "true", true},
{false, "true", true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("noSNAT=%v,noStateful=%q", tt.noSNAT, tt.noStateful), func(t *testing.T) {
prefs := ipn.NewPrefs()
prefs.Persist = &persist.Persist{
NodeID: tailcfg.StableNodeID("node1"),
UserProfile: tailcfg.UserProfile{
ID: tailcfg.UserID(1),
LoginName: "user1@example.com",
},
}
prefs.NoSNAT = tt.noSNAT
prefs.NoStatefulFiltering = tt.noStateful
// Make enough of a state store to load the prefs.
const profileName = "profile1"
bn := must.Get(json.Marshal(map[string]any{
string(ipn.CurrentProfileStateKey): []byte(profileName),
string(ipn.KnownProfilesStateKey): must.Get(json.Marshal(map[ipn.ProfileID]*ipn.LoginProfile{
profileName: {
ID: "profile1-id",
Key: profileName,
},
})),
profileName: prefs.ToBytes(),
}))
store := new(mem.Store)
err := store.LoadFromJSON([]byte(bn))
if err != nil {
t.Fatal(err)
}
ht := new(health.Tracker)
pm, err := newProfileManagerWithGOOS(store, t.Logf, ht, "linux")
if err != nil {
t.Fatal(err)
}
// Get the current profile and verify that we backfilled our
// StatefulFiltering boolean.
pf := pm.CurrentPrefs()
if !pf.NoStatefulFiltering().EqualBool(tt.want) {
t.Fatalf("got NoStatefulFiltering=%v, want %v", pf.NoStatefulFiltering(), tt.want)
}
})
}
}

@ -203,6 +203,21 @@ type Prefs struct {
// Linux-only.
NoSNAT bool
// NoStatefulFiltering specifies whether to apply stateful filtering
// when advertising routes in AdvertiseRoutes. The default is to apply
// stateful filtering.
//
// To allow inbound connections from advertised routes, both NoSNAT and
// NoStatefulFiltering must be true.
//
// This is an opt.Bool because it was added after NoSNAT, but is backfilled
// based on the value of that parameter. We need to treat it as a tristate:
// true, false, or unset, and backfill based on that value. See
// ipn/ipnlocal for more details on the backfill.
//
// Linux-only.
NoStatefulFiltering opt.Bool `json:",omitempty"`
// NetfilterMode specifies how much to manage netfilter rules for
// Tailscale, if at all.
NetfilterMode preftype.NetfilterMode
@ -302,6 +317,7 @@ type MaskedPrefs struct {
EggSet bool `json:",omitempty"`
AdvertiseRoutesSet bool `json:",omitempty"`
NoSNATSet bool `json:",omitempty"`
NoStatefulFilteringSet bool `json:",omitempty"`
NetfilterModeSet bool `json:",omitempty"`
OperatorUserSet bool `json:",omitempty"`
ProfileNameSet bool `json:",omitempty"`
@ -501,6 +517,13 @@ func (p *Prefs) pretty(goos string) string {
if len(p.AdvertiseRoutes) > 0 || p.NoSNAT {
fmt.Fprintf(&sb, "snat=%v ", !p.NoSNAT)
}
if len(p.AdvertiseRoutes) > 0 || p.NoStatefulFiltering.EqualBool(true) {
// Only print if we're advertising any routes, or the user has
// turned off stateful filtering (NoStatefulFiltering=true ⇒
// StatefulFiltering=false).
bb, _ := p.NoStatefulFiltering.Get()
fmt.Fprintf(&sb, "statefulFiltering=%v ", !bb)
}
if len(p.AdvertiseTags) > 0 {
fmt.Fprintf(&sb, "tags=%s ", strings.Join(p.AdvertiseTags, ","))
}
@ -569,6 +592,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.NotepadURLs == p2.NotepadURLs &&
p.ShieldsUp == p2.ShieldsUp &&
p.NoSNAT == p2.NoSNAT &&
p.NoStatefulFiltering == p2.NoStatefulFiltering &&
p.NetfilterMode == p2.NetfilterMode &&
p.OperatorUser == p2.OperatorUser &&
p.Hostname == p2.Hostname &&

@ -56,6 +56,7 @@ func TestPrefsEqual(t *testing.T) {
"Egg",
"AdvertiseRoutes",
"NoSNAT",
"NoStatefulFiltering",
"NetfilterMode",
"OperatorUser",
"ProfileName",

@ -133,7 +133,8 @@ type CapabilityVersion int
// - 90: 2024-04-03: Client understands PeerCapabilityTaildrive.
// - 91: 2024-04-24: Client understands PeerCapabilityTaildriveSharer.
// - 92: 2024-05-06: Client understands NodeAttrUserDialUseRoutes.
const CurrentCapabilityVersion CapabilityVersion = 92
// - 93: 2024-05-06: added support for stateful firewalling.
const CurrentCapabilityVersion CapabilityVersion = 93
type StableID string

@ -85,6 +85,15 @@ func (n *fakeIPTables) Delete(table, chain string, args ...string) error {
}
}
func (n *fakeIPTables) List(table, chain string) ([]string, error) {
k := table + "/" + chain
if rules, ok := n.n[k]; ok {
return rules, nil
} else {
return nil, fmt.Errorf("unknown table/chain %s", k)
}
}
func (n *fakeIPTables) ClearChain(table, chain string) error {
k := table + "/" + chain
if _, ok := n.n[k]; ok {

@ -12,6 +12,7 @@ import (
"net/netip"
"os"
"os/exec"
"slices"
"strconv"
"strings"
@ -36,6 +37,7 @@ type iptablesInterface interface {
Append(table, chain string, args ...string) error
Exists(table, chain string, args ...string) (bool, error)
Delete(table, chain string, args ...string) error
List(table, chain string) ([]string, error)
ClearChain(table, chain string) error
NewChain(table, chain string) error
DeleteChain(table, chain string) error
@ -530,6 +532,67 @@ func (i *iptablesRunner) DelSNATRule() error {
return nil
}
func statefulRuleArgs(tunname string) []string {
return []string{"-o", tunname, "-m", "conntrack", "!", "--ctstate", "ESTABLISHED,RELATED", "-j", "DROP"}
}
// AddStatefulRule adds a netfilter rule for stateful packet filtering using
// conntrack.
func (i *iptablesRunner) AddStatefulRule(tunname string) error {
// Drop packets that are destined for the tailscale interface if
// they're a new connection, per conntrack, to prevent hosts on the
// same subnet from being able to use this device as a way to forward
// packets on to the Tailscale network.
//
// The conntrack states are:
// NEW A packet which creates a new connection.
// ESTABLISHED A packet which belongs to an existing connection
// (i.e., a reply packet, or outgoing packet on a
// connection which has seen replies).
// RELATED A packet which is related to, but not part of, an
// existing connection, such as an ICMP error.
// INVALID A packet which could not be identified for some
// reason: this includes running out of memory and ICMP
// errors which don't correspond to any known
// connection. Generally these packets should be
// dropped.
//
// We drop NEW packets to prevent connections from coming "into"
// Tailscale from other hosts on the same network segment; we drop
// INVALID packets as well.
args := statefulRuleArgs(tunname)
for _, ipt := range i.getTables() {
// First, find the final "accept" rule.
rules, err := ipt.List("filter", "ts-forward")
if err != nil {
return fmt.Errorf("listing rules in filter/ts-forward: %w", err)
}
want := fmt.Sprintf("-A %s -o %s -j ACCEPT", "ts-forward", tunname)
pos := slices.Index(rules, want)
if pos < 0 {
return fmt.Errorf("couldn't find final ACCEPT rule in filter/ts-forward")
}
if err := ipt.Insert("filter", "ts-forward", pos, args...); err != nil {
return fmt.Errorf("adding %v in filter/ts-forward: %w", args, err)
}
}
return nil
}
// DelStatefulRule removes the netfilter rule for stateful packet filtering
// using conntrack.
func (i *iptablesRunner) DelStatefulRule(tunname string) error {
args := statefulRuleArgs(tunname)
for _, ipt := range i.getTables() {
if err := ipt.Delete("filter", "ts-forward", args...); err != nil {
return fmt.Errorf("deleting %v in filter/ts-forward: %w", args, err)
}
}
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

@ -514,6 +514,14 @@ type NetfilterRunner interface {
// DelSNATRule removes the rule added by AddSNATRule.
DelSNATRule() error
// AddStatefulRule adds a netfilter rule for stateful packet filtering
// using conntrack.
AddStatefulRule(tunname string) error
// DelStatefulRule removes a netfilter rule for stateful packet filtering
// using conntrack.
DelStatefulRule(tunname string) error
// HasIPV6 reports true if the system supports IPv6.
HasIPV6() bool
@ -1748,6 +1756,194 @@ func (n *nftablesRunner) DelSNATRule() error {
return nil
}
func nativeUint32(v uint32) []byte {
b := make([]byte, 4)
binary.NativeEndian.PutUint32(b, v)
return b
}
func makeStatefulRuleExprs(tunname string) []expr.Any {
return []expr.Any{
// Check if the output interface is the Tailscale interface by
// first loding the OIFNAME into register 1 and comparing it
// against our tunname.
//
// 'cmp' implicitly breaks from a rule if a comparison fails,
// so if we continue past this rule we know that the packet is
// going to our TUN.
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
Data: []byte(tunname),
},
// Store the conntrack state in register 1
&expr.Ct{
Register: 1,
Key: expr.CtKeySTATE,
},
// Mask the state in register 1 to "hide" the ESTABLISHED and
// RELATED bits (which are expected and fine); if there are any
// other bits, we want them to remain.
//
// This operation is, in the kernel:
// dst[i] = (src[i] & mask[i]) ^ xor[i]
//
// So, we can mask by setting the inverse of the bits we want
// to remove; i.e. ESTABLISHED = 0b00000010, RELATED =
// 0b00000100, so, if we assume an 8-bit state (in reality,
// it's 32-bit), we can mask with 0b11111001 to clear those
// bits and keep everything else (e.g. the INVALID bit which is
// 0b00000001).
//
// TODO(andrew-d): for now, let's also allow
// CtStateBitUNTRACKED, which is a state for packets that are not
// tracked (marked so explicitly with an iptables rule using
// --notrack); we should figure out if we want to allow this or not.
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4,
Mask: nativeUint32(^(0 |
expr.CtStateBitESTABLISHED |
expr.CtStateBitRELATED |
expr.CtStateBitUNTRACKED)),
// Xor is unused but must be specified
Xor: nativeUint32(0),
},
// Compare against the expected state (0, i.e. no bits set
// other than maybe ESTABLISHED and RELATED). We want this
// comparison to fail if there are no bits set, so that this
// rule's evaluation stops and we don't fall through to the
// "Drop" verdict.
//
// For example, if the state is ESTABLISHED (and we want to
// break from this rule/accept this packet):
// state = ESTABLISHED
// register1 = 0b0 (since the bitwise operation cleared the ESTABLISHED bit)
//
// compare register1 (0b0) != 0: false
// -> comparison implicitly breaks
// -> continue to the next rule
//
// For example, if the state is NEW (and we want to continue to
// the next expression and thus drop this packet):
// state = NEW
// register1 = 0b1000
//
// compare register1 (0b1000) != 0: true
// -> comparison continues to next expr
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
Data: []byte{0, 0, 0, 0},
},
// If we get here, we know that this packet is going to our TUN
// device, and has a conntrack state set other than ESTABLISHED
// or RELATED. We thus count and drop the packet.
&expr.Counter{},
&expr.Verdict{Kind: expr.VerdictDrop},
}
// TODO(andrew-d): iptables-nft writes a rule that dumps as:
//
// match name conntrack rev 3
//
// I think this is using expr.Match against the following struct
// (xt_conntrack_mtinfo3):
//
// https://github.com/torvalds/linux/blob/master/include/uapi/linux/netfilter/xt_conntrack.h#L64-L77
//
// We could probably do something similar here, but I'm not sure if
// there's any advantage. Below is an example Match statement if we
// decide to do that, based on dumping the rule that iptables-nft
// generates:
//
// _ = expr.Match{
// Name: "conntrack",
// Rev: 3,
// Info: &xt.ConntrackMtinfo3{
// ConntrackMtinfo2: xt.ConntrackMtinfo2{
// ConntrackMtinfoBase: xt.ConntrackMtinfoBase{
// MatchFlags: xt.ConntrackState,
// InvertFlags: xt.ConntrackState,
// },
// // Mask the state to remove ESTABLISHED and
// // RELATED before comparing.
// StateMask: expr.CtStateBitESTABLISHED | expr.CtStateBitRELATED,
// },
// },
// }
}
// AddStatefulRule adds a netfilter rule for stateful packet filtering using
// conntrack.
func (n *nftablesRunner) AddStatefulRule(tunname string) error {
conn := n.conn
exprs := makeStatefulRuleExprs(tunname)
for _, table := range n.getTables() {
chain, err := getChainFromTable(conn, table.Filter, chainNameForward)
if err != nil {
return fmt.Errorf("get forward chain: %w", err)
}
// First, find the 'accept' rule that we want to insert our rule before.
acceptRule := createAcceptOutgoingPacketRule(table.Filter, chain, tunname)
rule, err := findRule(conn, acceptRule)
if err != nil {
return fmt.Errorf("find accept rule: %w", err)
}
conn.InsertRule(&nftables.Rule{
Table: table.Filter,
Chain: chain,
Exprs: exprs,
// Specifying Position in an Insert operation means to
// insert this rule before the specified rule.
Position: rule.Handle,
})
}
if err := conn.Flush(); err != nil {
return fmt.Errorf("flush add stateful rule: %w", err)
}
return nil
}
// DelStatefulRule removes the netfilter rule for stateful packet filtering
// using conntrack.
func (n *nftablesRunner) DelStatefulRule(tunname string) error {
conn := n.conn
exprs := makeStatefulRuleExprs(tunname)
for _, table := range n.getTables() {
chain, err := getChainFromTable(conn, table.Filter, chainNameForward)
if err != nil {
return fmt.Errorf("get forward chain: %w", err)
}
rule, err := findRule(conn, &nftables.Rule{
Table: table.Nat,
Chain: chain,
Exprs: exprs,
})
if err != nil {
return fmt.Errorf("find stateful rule: %w", err)
}
if rule != nil {
conn.DelRule(rule)
}
}
if err := conn.Flush(); err != nil {
return fmt.Errorf("flush del stateful rule: %w", err)
}
return nil
}
// cleanupChain removes a jump rule from hookChainName to tsChainName, and then
// the entire chain tsChainName. Errors are logged, but attempts to remove both
// the jump rule and chain continue even if one errors.

@ -89,6 +89,7 @@ type Config struct {
// 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)
}

@ -47,6 +47,7 @@ type linuxRouter struct {
routes map[netip.Prefix]bool
localRoutes map[netip.Prefix]bool
snatSubnetRoutes bool
statefulFiltering bool
netfilterMode preftype.NetfilterMode
netfilterKind string
@ -390,6 +391,7 @@ func (r *linuxRouter) Set(cfg *Config) error {
}
r.addrs = newAddrs
// Ensure that the SNAT rule is added or removed as needed.
switch {
case cfg.SNATSubnetRoutes == r.snatSubnetRoutes:
// state already correct, nothing to do.
@ -404,6 +406,21 @@ func (r *linuxRouter) Set(cfg *Config) error {
}
r.snatSubnetRoutes = cfg.SNATSubnetRoutes
// As above, for stateful filtering
switch {
case cfg.StatefulFiltering == r.statefulFiltering:
// state already correct, nothing to do.
case cfg.StatefulFiltering:
if err := r.addStatefulRule(); err != nil {
errs = append(errs, err)
}
default:
if err := r.delStatefulRule(); err != nil {
errs = append(errs, err)
}
}
r.statefulFiltering = cfg.StatefulFiltering
// Issue 11405: enable IP forwarding on gokrazy.
advertisingRoutes := len(cfg.SubnetRoutes) > 0
if distro.Get() == distro.Gokrazy && advertisingRoutes {
@ -1327,6 +1344,26 @@ func (r *linuxRouter) delSNATRule() error {
return nil
}
// addStatefulRule adds a netfilter rule to perform stateful filtering from
// subnets onto the tailnet.
func (r *linuxRouter) addStatefulRule() error {
if r.netfilterMode == netfilterOff {
return nil
}
return r.nfr.AddStatefulRule(r.tunname)
}
// delStatefulRule removes the netfilter rule to perform stateful filtering
// from subnets onto the tailnet.
func (r *linuxRouter) delStatefulRule() error {
if r.netfilterMode == netfilterOff {
return nil
}
return r.nfr.DelStatefulRule(r.tunname)
}
// cidrDiff calls add and del as needed to make the set of prefixes in
// old and new match. Returns a map reflecting the actual new state
// (which may be somewhere in between old and new if some commands

@ -98,6 +98,44 @@ ip route add 192.168.16.0/24 dev tailscale0 table 52` + basic,
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
SubnetRoutes: mustCIDRs("200.0.0.0/8"),
SNATSubnetRoutes: true,
StatefulFiltering: true,
NetfilterMode: netfilterOn,
},
want: `
up
ip addr add 100.101.102.104/10 dev tailscale0
ip route add 10.0.0.0/8 dev tailscale0 table 52
ip route add 100.100.100.100/32 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 -m conntrack ! --ctstate ESTABLISHED,RELATED -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/nat/POSTROUTING -j ts-postrouting
v4/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
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 -m conntrack ! --ctstate ESTABLISHED,RELATED -j DROP
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/nat/POSTROUTING -j ts-postrouting
v6/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
`,
},
{
name: "addr and routes and subnet routes with netfilter but no stateful filtering",
in: &Config{
LocalAddrs: mustCIDRs("100.101.102.104/10"),
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
SubnetRoutes: mustCIDRs("200.0.0.0/8"),
SNATSubnetRoutes: true,
StatefulFiltering: false,
NetfilterMode: netfilterOn,
},
want: `
@ -411,6 +449,22 @@ func insertRule(n *fakeIPTablesRunner, curIPT map[string][]string, chain, newRul
return nil
}
func insertRuleAt(n *fakeIPTablesRunner, curIPT map[string][]string, chain string, pos int, newRule string) {
rules, ok := curIPT[chain]
if !ok {
n.t.Fatalf("no %s chain exists", chain)
}
// If the given position is after the end of the chain, error.
if pos > len(rules) {
n.t.Fatalf("position %d > len(chain %s) %d", pos, chain, len(chain))
}
// Insert the rule at the given position
rules = slices.Insert(rules, pos, newRule)
curIPT[chain] = rules
}
func appendRule(n *fakeIPTablesRunner, curIPT map[string][]string, chain, newRule string) error {
// Get current rules for filter/ts-input chain with according IP version
curTSInputRules, ok := curIPT[chain]
@ -611,6 +665,33 @@ func (n *fakeIPTablesRunner) DelSNATRule() error {
return nil
}
func (n *fakeIPTablesRunner) AddStatefulRule(tunname string) error {
newRule := fmt.Sprintf("-o %s -m conntrack ! --ctstate ESTABLISHED,RELATED -j DROP", tunname)
for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} {
// Mimic the real runner and insert after the 'accept all' rule
wantRule := fmt.Sprintf("-o %s -j ACCEPT", tunname)
const chain = "filter/ts-forward"
pos := slices.Index(ipt[chain], wantRule)
if pos < 0 {
n.t.Fatalf("no rule %q in chain %s", wantRule, chain)
}
insertRuleAt(n, ipt, chain, pos, newRule)
}
return nil
}
func (n *fakeIPTablesRunner) DelStatefulRule(tunname string) error {
delRule := fmt.Sprintf("-o %s -m conntrack ! --ctstate ESTABLISHED,RELATED -j DROP", tunname)
for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} {
if err := deleteRule(n, ipt, "filter/ts-forward", delRule); err != nil {
return err
}
}
return nil
}
// buildMagicsockPortRule builds a fake rule to use in AddMagicsockPortRule and
// DelMagicsockPortRule below.
func buildMagicsockPortRule(port uint16) string {

@ -23,8 +23,8 @@ func mustCIDRs(ss ...string) []netip.Prefix {
func TestConfigEqual(t *testing.T) {
testedFields := []string{
"LocalAddrs", "Routes", "LocalRoutes", "NewMTU",
"SubnetRoutes", "SNATSubnetRoutes", "NetfilterMode",
"NetfilterKind",
"SubnetRoutes", "SNATSubnetRoutes", "StatefulFiltering",
"NetfilterMode", "NetfilterKind",
}
configType := reflect.TypeFor[Config]()
configFields := []string{}
@ -125,6 +125,16 @@ func TestConfigEqual(t *testing.T) {
&Config{SNATSubnetRoutes: false},
true,
},
{
&Config{StatefulFiltering: false},
&Config{StatefulFiltering: true},
false,
},
{
&Config{StatefulFiltering: false},
&Config{StatefulFiltering: false},
true,
},
{
&Config{NetfilterMode: preftype.NetfilterOff},

Loading…
Cancel
Save