diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index d7b43fa96..4a617aa13 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -530,7 +530,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de regexp/syntax from regexp runtime/debug from github.com/klauspost/compress/zstd+ 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+ sort 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/atomic from context+ syscall from crypto/rand+ + testing from tailscale.com/util/syspolicy text/tabwriter from runtime/pprof text/template from html/template text/template/parse from html/template+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 842e38406..eadf78f49 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -88,6 +88,7 @@ "tailscale.com/util/osshare" "tailscale.com/util/rands" "tailscale.com/util/set" + "tailscale.com/util/syspolicy" "tailscale.com/util/systemd" "tailscale.com/util/testenv" "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 // than by IP. It returns whether prefs was mutated. 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 { // No netmap, can't resolve anything. return false @@ -1338,7 +1357,7 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) // reference it directly for next time. prefs.ExitNodeID = peer.StableID() prefs.ExitNodeIP = netip.Addr{} - return true + return oldExitNodeID != prefs.ExitNodeID } } diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index eaaf20bc1..e41a818a4 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -35,6 +35,7 @@ "tailscale.com/util/mak" "tailscale.com/util/must" "tailscale.com/util/set" + "tailscale.com/util/syspolicy" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" "tailscale.com/wgengine/wgcfg" @@ -1330,3 +1331,272 @@ func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error { rc.routes = append(rc.routes, pfx) 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) + } + }) + } +} diff --git a/util/syspolicy/handler.go b/util/syspolicy/handler.go index 68ba09176..7a98161c8 100644 --- a/util/syspolicy/handler.go +++ b/util/syspolicy/handler.go @@ -6,6 +6,7 @@ import ( "errors" "sync/atomic" + "testing" ) var ( @@ -56,3 +57,10 @@ func RegisterHandler(h Handler) { 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 }) +} diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go index daeaa03b5..8dc2d00c2 100644 --- a/util/syspolicy/policy_keys.go +++ b/util/syspolicy/policy_keys.go @@ -10,6 +10,11 @@ ControlURL Key = "LoginURL" // default ""; if blank, ipn uses ipn.DefaultControlURL. LogTarget Key = "LogTarget" // default ""; if blank logging uses logtail.DefaultHost. 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". // The default is "user-decides" unless otherwise stated. diff --git a/util/syspolicy/syspolicy_test.go b/util/syspolicy/syspolicy_test.go index ea6749ce3..c01d93583 100644 --- a/util/syspolicy/syspolicy_test.go +++ b/util/syspolicy/syspolicy_test.go @@ -24,13 +24,6 @@ type testHandler struct { 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) { if key != string(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 { t.Run(tt.name, func(t *testing.T) { - setHandlerForTest(t, &testHandler{ + SetHandlerForTest(t, &testHandler{ t: t, key: tt.key, s: tt.handlerValue, @@ -152,7 +145,7 @@ func TestGetUint64(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - setHandlerForTest(t, &testHandler{ + SetHandlerForTest(t, &testHandler{ t: t, key: tt.key, u64: tt.handlerValue, @@ -204,7 +197,7 @@ func TestGetBoolean(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - setHandlerForTest(t, &testHandler{ + SetHandlerForTest(t, &testHandler{ t: t, key: tt.key, b: tt.handlerValue, @@ -265,7 +258,7 @@ func TestGetPreferenceOption(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - setHandlerForTest(t, &testHandler{ + SetHandlerForTest(t, &testHandler{ t: t, key: tt.key, s: tt.handlerValue, @@ -322,7 +315,7 @@ func TestGetVisibility(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - setHandlerForTest(t, &testHandler{ + SetHandlerForTest(t, &testHandler{ t: t, key: tt.key, s: tt.handlerValue, @@ -389,7 +382,7 @@ func TestGetDuration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - setHandlerForTest(t, &testHandler{ + SetHandlerForTest(t, &testHandler{ t: t, key: tt.key, s: tt.handlerValue,