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>
This commit is contained in:
Andrew Lytvynov
2024-05-06 15:22:17 -07:00
committed by GitHub
parent 5ef178fdca
commit c28f5767bf
17 changed files with 632 additions and 47 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -4153,13 +4153,33 @@ 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(),
NetfilterMode: prefs.NetfilterMode(),
Routes: peerRoutes(b.logf, cfg.Peers, singleRouteThreshold),
NetfilterKind: netfilterKind,
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,
}
if distro.Get() == distro.Synology {

View File

@@ -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()
}()

View File

@@ -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)
}
})
}
}

View File

@@ -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 &&

View File

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