syspolicy: add exit node related policies (#10172)

Adds policy keys ExitNodeID and ExitNodeIP.
Uses the policy keys to determine the exit node in preferences.
Fixes tailscale/corp#15683

Signed-off-by: Claire Wang <claire@tailscale.com>
pull/8743/merge
Claire Wang 1 year ago committed by GitHub
parent ecd1ccb917
commit 8af503b0c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -530,7 +530,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
regexp/syntax from regexp regexp/syntax from regexp
runtime/debug from github.com/klauspost/compress/zstd+ runtime/debug from github.com/klauspost/compress/zstd+
runtime/pprof from tailscale.com/ipn/ipnlocal+ runtime/pprof from tailscale.com/ipn/ipnlocal+
runtime/trace from net/http/pprof runtime/trace from net/http/pprof+
slices from tailscale.com/wgengine/magicsock+ slices from tailscale.com/wgengine/magicsock+
sort from compress/flate+ sort from compress/flate+
strconv from compress/flate+ strconv from compress/flate+
@ -538,6 +538,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
sync from compress/flate+ sync from compress/flate+
sync/atomic from context+ sync/atomic from context+
syscall from crypto/rand+ syscall from crypto/rand+
testing from tailscale.com/util/syspolicy
text/tabwriter from runtime/pprof text/tabwriter from runtime/pprof
text/template from html/template text/template from html/template
text/template/parse from html/template+ text/template/parse from html/template+

@ -88,6 +88,7 @@ import (
"tailscale.com/util/osshare" "tailscale.com/util/osshare"
"tailscale.com/util/rands" "tailscale.com/util/rands"
"tailscale.com/util/set" "tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/util/systemd" "tailscale.com/util/systemd"
"tailscale.com/util/testenv" "tailscale.com/util/testenv"
"tailscale.com/util/uniq" "tailscale.com/util/uniq"
@ -1311,6 +1312,24 @@ func (b *LocalBackend) updateNetmapDeltaLocked(muts []netmap.NodeMutation) (hand
// setExitNodeID updates prefs to reference an exit node by ID, rather // setExitNodeID updates prefs to reference an exit node by ID, rather
// than by IP. It returns whether prefs was mutated. // than by IP. It returns whether prefs was mutated.
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) { func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
changed := prefs.ExitNodeID != exitNodeID || prefs.ExitNodeIP.IsValid()
prefs.ExitNodeID = exitNodeID
prefs.ExitNodeIP = netip.Addr{}
return changed
}
oldExitNodeID := prefs.ExitNodeID
if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" {
exitNodeIP, err := netip.ParseAddr(exitNodeIPStr)
if exitNodeIP.IsValid() && err == nil {
prefsChanged = prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP
prefs.ExitNodeID = ""
prefs.ExitNodeIP = exitNodeIP
}
}
if nm == nil { if nm == nil {
// No netmap, can't resolve anything. // No netmap, can't resolve anything.
return false return false
@ -1338,7 +1357,7 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool)
// reference it directly for next time. // reference it directly for next time.
prefs.ExitNodeID = peer.StableID() prefs.ExitNodeID = peer.StableID()
prefs.ExitNodeIP = netip.Addr{} prefs.ExitNodeIP = netip.Addr{}
return true return oldExitNodeID != prefs.ExitNodeID
} }
} }

@ -35,6 +35,7 @@ import (
"tailscale.com/util/mak" "tailscale.com/util/mak"
"tailscale.com/util/must" "tailscale.com/util/must"
"tailscale.com/util/set" "tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/wgengine" "tailscale.com/wgengine"
"tailscale.com/wgengine/filter" "tailscale.com/wgengine/filter"
"tailscale.com/wgengine/wgcfg" "tailscale.com/wgengine/wgcfg"
@ -1330,3 +1331,272 @@ func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx) rc.routes = append(rc.routes, pfx)
return nil return nil
} }
type mockSyspolicyHandler struct {
t *testing.T
key syspolicy.Key
s string
exitNodeIDKey bool
exitNodeIPKey bool
exitNodeID string
exitNodeIP string
}
func (h *mockSyspolicyHandler) ReadString(key string) (string, error) {
if h.exitNodeIDKey && key == string(syspolicy.ExitNodeID) {
return h.exitNodeID, nil
}
if h.exitNodeIPKey && key == string(syspolicy.ExitNodeIP) {
return h.exitNodeIP, nil
}
return "", syspolicy.ErrNoSuchKey
}
func (h *mockSyspolicyHandler) ReadUInt64(key string) (uint64, error) {
h.t.Errorf("ReadUInt64(%q) unexpectedly called", key)
return 0, syspolicy.ErrNoSuchKey
}
func (h *mockSyspolicyHandler) ReadBoolean(key string) (bool, error) {
h.t.Errorf("ReadBoolean(%q) unexpectedly called", key)
return false, syspolicy.ErrNoSuchKey
}
func TestSetExitNodeIDPolicy(t *testing.T) {
pfx := netip.MustParsePrefix
tests := []struct {
name string
exitNodeIPKey bool
exitNodeIDKey bool
exitNodeID string
exitNodeIP string
prefs *ipn.Prefs
exitNodeIPWant string
exitNodeIDWant string
prefsChanged bool
nm *netmap.NetworkMap
}{
{
name: "ExitNodeID key is set",
exitNodeIDKey: true,
exitNodeID: "123",
exitNodeIDWant: "123",
prefsChanged: true,
},
{
name: "ExitNodeID key not set",
exitNodeIDKey: true,
exitNodeIDWant: "",
prefsChanged: false,
},
{
name: "ExitNodeID key set, ExitNodeIP preference set",
exitNodeIDKey: true,
exitNodeID: "123",
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")},
exitNodeIDWant: "123",
prefsChanged: true,
},
{
name: "ExitNodeID key not set, ExitNodeIP key set",
exitNodeIPKey: true,
exitNodeIP: "127.0.0.1",
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")},
exitNodeIPWant: "127.0.0.1",
prefsChanged: false,
},
{
name: "ExitNodeIP key set, existing ExitNodeIP pref",
exitNodeIPKey: true,
exitNodeIP: "127.0.0.1",
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")},
exitNodeIPWant: "127.0.0.1",
prefsChanged: false,
},
{
name: "existing preferences match policy",
exitNodeIDKey: true,
exitNodeID: "123",
prefs: &ipn.Prefs{ExitNodeID: tailcfg.StableNodeID("123")},
exitNodeIDWant: "123",
prefsChanged: false,
},
{
name: "ExitNodeIP set if net map does not have corresponding node",
exitNodeIPKey: true,
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")},
exitNodeIP: "127.0.0.1",
exitNodeIPWant: "127.0.0.1",
prefsChanged: false,
nm: &netmap.NetworkMap{
Name: "foo.tailnet",
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{
pfx("100.102.103.104/32"),
pfx("100::123/128"),
},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
Name: "a.tailnet",
Addresses: []netip.Prefix{
pfx("100.0.0.201/32"),
pfx("100::201/128"),
},
}).View(),
(&tailcfg.Node{
Name: "b.tailnet",
Addresses: []netip.Prefix{
pfx("100::202/128"),
},
}).View(),
},
},
},
{
name: "ExitNodeIP cleared if net map has corresponding node - policy matches prefs",
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")},
exitNodeIPKey: true,
exitNodeIP: "127.0.0.1",
exitNodeIPWant: "",
exitNodeIDWant: "123",
prefsChanged: true,
nm: &netmap.NetworkMap{
Name: "foo.tailnet",
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{
pfx("100.102.103.104/32"),
pfx("100::123/128"),
},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
Name: "a.tailnet",
StableID: tailcfg.StableNodeID("123"),
Addresses: []netip.Prefix{
pfx("127.0.0.1/32"),
pfx("100::201/128"),
},
}).View(),
(&tailcfg.Node{
Name: "b.tailnet",
Addresses: []netip.Prefix{
pfx("100::202/128"),
},
}).View(),
},
},
},
{
name: "ExitNodeIP cleared if net map has corresponding node - no policy set",
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")},
exitNodeIPWant: "",
exitNodeIDWant: "123",
prefsChanged: true,
nm: &netmap.NetworkMap{
Name: "foo.tailnet",
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{
pfx("100.102.103.104/32"),
pfx("100::123/128"),
},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
Name: "a.tailnet",
StableID: tailcfg.StableNodeID("123"),
Addresses: []netip.Prefix{
pfx("127.0.0.1/32"),
pfx("100::201/128"),
},
}).View(),
(&tailcfg.Node{
Name: "b.tailnet",
Addresses: []netip.Prefix{
pfx("100::202/128"),
},
}).View(),
},
},
},
{
name: "ExitNodeIP cleared if net map has corresponding node - different exit node IP in policy",
exitNodeIPKey: true,
prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")},
exitNodeIP: "100.64.5.6",
exitNodeIPWant: "",
exitNodeIDWant: "123",
prefsChanged: true,
nm: &netmap.NetworkMap{
Name: "foo.tailnet",
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{
pfx("100.102.103.104/32"),
pfx("100::123/128"),
},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
Name: "a.tailnet",
StableID: tailcfg.StableNodeID("123"),
Addresses: []netip.Prefix{
pfx("100.64.5.6/32"),
pfx("100::201/128"),
},
}).View(),
(&tailcfg.Node{
Name: "b.tailnet",
Addresses: []netip.Prefix{
pfx("100::202/128"),
},
}).View(),
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
b := newTestBackend(t)
syspolicy.SetHandlerForTest(t, &mockSyspolicyHandler{
t: t,
exitNodeID: test.exitNodeID,
exitNodeIP: test.exitNodeIP,
exitNodeIDKey: test.exitNodeIDKey,
exitNodeIPKey: test.exitNodeIPKey,
})
if test.nm == nil {
test.nm = new(netmap.NetworkMap)
}
if test.prefs == nil {
test.prefs = ipn.NewPrefs()
}
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
pm.prefs = test.prefs.View()
b.netMap = test.nm
b.pm = pm
changed := setExitNodeID(b.pm.prefs.AsStruct(), test.nm)
b.SetPrefs(pm.CurrentPrefs().AsStruct())
if test.exitNodeIDKey {
got := b.pm.prefs.ExitNodeID()
if got != tailcfg.StableNodeID(test.exitNodeIDWant) {
t.Errorf("got %v want %v", got, test.exitNodeIDWant)
}
}
if test.exitNodeIPKey {
got := b.pm.prefs.ExitNodeIP()
if test.exitNodeIPWant == "" {
if got.String() != "invalid IP" {
t.Errorf("got %v want invalid IP", got)
}
} else if got.String() != test.exitNodeIPWant {
t.Errorf("got %v want %v", got, test.exitNodeIPWant)
}
}
if changed != test.prefsChanged {
t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed)
}
})
}
}

@ -6,6 +6,7 @@ package syspolicy
import ( import (
"errors" "errors"
"sync/atomic" "sync/atomic"
"testing"
) )
var ( var (
@ -56,3 +57,10 @@ func RegisterHandler(h Handler) {
panic("handler was already used before registration") panic("handler was already used before registration")
} }
} }
func SetHandlerForTest(tb testing.TB, h Handler) {
tb.Helper()
oldHandler := handler
handler = h
tb.Cleanup(func() { handler = oldHandler })
}

@ -10,6 +10,11 @@ const (
ControlURL Key = "LoginURL" // default ""; if blank, ipn uses ipn.DefaultControlURL. ControlURL Key = "LoginURL" // default ""; if blank, ipn uses ipn.DefaultControlURL.
LogTarget Key = "LogTarget" // default ""; if blank logging uses logtail.DefaultHost. LogTarget Key = "LogTarget" // default ""; if blank logging uses logtail.DefaultHost.
Tailnet Key = "Tailnet" // default ""; if blank, no tailnet name is sent to the server. Tailnet Key = "Tailnet" // default ""; if blank, no tailnet name is sent to the server.
// ExitNodeID is the exit node's node id. default ""; if blank, no exit node is forced.
// Exit node ID takes precedence over exit node IP.
// To find the node ID, go to /api.md#device.
ExitNodeID Key = "ExitNodeID"
ExitNodeIP Key = "ExitNodeIP" // default ""; if blank, no exit node is forced. Value is exit node IP.
// Keys with a string value that specifies an option: "always", "never", "user-decides". // Keys with a string value that specifies an option: "always", "never", "user-decides".
// The default is "user-decides" unless otherwise stated. // The default is "user-decides" unless otherwise stated.

@ -24,13 +24,6 @@ type testHandler struct {
var someOtherError = errors.New("error other than not found") var someOtherError = errors.New("error other than not found")
func setHandlerForTest(tb testing.TB, h Handler) {
tb.Helper()
oldHandler := handler
handler = h
tb.Cleanup(func() { handler = oldHandler })
}
func (th *testHandler) ReadString(key string) (string, error) { func (th *testHandler) ReadString(key string) (string, error) {
if key != string(th.key) { if key != string(th.key) {
th.t.Errorf("ReadString(%q) want %q", key, th.key) th.t.Errorf("ReadString(%q) want %q", key, th.key)
@ -95,7 +88,7 @@ func TestGetString(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{ SetHandlerForTest(t, &testHandler{
t: t, t: t,
key: tt.key, key: tt.key,
s: tt.handlerValue, s: tt.handlerValue,
@ -152,7 +145,7 @@ func TestGetUint64(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{ SetHandlerForTest(t, &testHandler{
t: t, t: t,
key: tt.key, key: tt.key,
u64: tt.handlerValue, u64: tt.handlerValue,
@ -204,7 +197,7 @@ func TestGetBoolean(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{ SetHandlerForTest(t, &testHandler{
t: t, t: t,
key: tt.key, key: tt.key,
b: tt.handlerValue, b: tt.handlerValue,
@ -265,7 +258,7 @@ func TestGetPreferenceOption(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{ SetHandlerForTest(t, &testHandler{
t: t, t: t,
key: tt.key, key: tt.key,
s: tt.handlerValue, s: tt.handlerValue,
@ -322,7 +315,7 @@ func TestGetVisibility(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{ SetHandlerForTest(t, &testHandler{
t: t, t: t,
key: tt.key, key: tt.key,
s: tt.handlerValue, s: tt.handlerValue,
@ -389,7 +382,7 @@ func TestGetDuration(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{ SetHandlerForTest(t, &testHandler{
t: t, t: t,
key: tt.key, key: tt.key,
s: tt.handlerValue, s: tt.handlerValue,

Loading…
Cancel
Save