diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 8665a88c4..ce0f4f687 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -5612,6 +5612,11 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip // WireIngress. hi.WireIngress = b.shouldWireInactiveIngressLocked() hi.AppConnector.Set(prefs.AppConnector().Advertise) + + // The [tailcfg.Hostinfo.ExitNodeID] field tells control which exit node + // was selected, if any. Since [LocalBackend.resolveExitNodeIPLocked] + // has already run, there is no need to consult [ipn.Prefs.ExitNodeIP]. + hi.ExitNodeID = prefs.ExitNodeID() } // enterState transitions the backend into newState, updating internal @@ -6136,6 +6141,10 @@ func (b *LocalBackend) resolveExitNode() (changed bool) { }); err != nil { b.logf("failed to save exit node changes: %v", err) } + + // Send the resolved exit node to Control via Hostinfo. + b.hostinfo.ExitNodeID = prefs.ExitNodeID + b.sendToLocked(ipn.Notify{Prefs: ptr.To(prefs.View())}, allClients) return true } diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 13681fc04..da6fc8b4a 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -614,19 +614,20 @@ func TestConfigureExitNode(t *testing.T) { } tests := []struct { - name string - prefs ipn.Prefs - netMap *netmap.NetworkMap - report *netcheck.Report - changePrefs *ipn.MaskedPrefs - useExitNodeEnabled *bool - exitNodeIDPolicy *tailcfg.StableNodeID - exitNodeIPPolicy *netip.Addr - exitNodeAllowedIDs []tailcfg.StableNodeID // nil if all IDs are allowed for auto exit nodes - exitNodeAllowOverride bool // whether [syspolicy.AllowExitNodeOverride] should be set to true - wantChangePrefsErr error // if non-nil, the error we expect from [LocalBackend.EditPrefsAs] - wantPrefs ipn.Prefs - wantExitNodeToggleErr error // if non-nil, the error we expect from [LocalBackend.SetUseExitNodeEnabled] + name string + prefs ipn.Prefs + netMap *netmap.NetworkMap + report *netcheck.Report + changePrefs *ipn.MaskedPrefs + useExitNodeEnabled *bool + exitNodeIDPolicy *tailcfg.StableNodeID + exitNodeIPPolicy *netip.Addr + exitNodeAllowedIDs []tailcfg.StableNodeID // nil if all IDs are allowed for auto exit nodes + exitNodeAllowOverride bool // whether [syspolicy.AllowExitNodeOverride] should be set to true + wantChangePrefsErr error // if non-nil, the error we expect from [LocalBackend.EditPrefsAs] + wantPrefs ipn.Prefs + wantExitNodeToggleErr error // if non-nil, the error we expect from [LocalBackend.SetUseExitNodeEnabled] + wantHostinfoExitNodeID *tailcfg.StableNodeID }{ { name: "exit-node-id-via-prefs", // set exit node ID via prefs @@ -643,6 +644,7 @@ func TestConfigureExitNode(t *testing.T) { ControlURL: controlURL, ExitNodeID: exitNode1.StableID(), }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "exit-node-ip-via-prefs", // set exit node IP via prefs (should be resolved to an ID) @@ -659,6 +661,7 @@ func TestConfigureExitNode(t *testing.T) { ControlURL: controlURL, ExitNodeID: exitNode1.StableID(), }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-exit-node-via-prefs/any", // set auto exit node via prefs @@ -676,6 +679,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode1.StableID(), AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-exit-node-via-prefs/set-exit-node-id-via-prefs", // setting exit node ID explicitly should disable auto exit node @@ -695,6 +699,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode2.StableID(), AutoExitNode: "", // should be unset }, + wantHostinfoExitNodeID: ptr.To(exitNode2.StableID()), }, { name: "auto-exit-node-via-prefs/any/no-report", // set auto exit node via prefs, but no report means we can't resolve the exit node ID @@ -711,6 +716,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: unresolvedExitNodeID, // cannot resolve; traffic will be dropped AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(unresolvedExitNodeID), }, { name: "auto-exit-node-via-prefs/any/no-netmap", // similarly, but without a netmap (no exit node should be selected) @@ -727,6 +733,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: unresolvedExitNodeID, // cannot resolve; traffic will be dropped AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(unresolvedExitNodeID), }, { name: "auto-exit-node-via-prefs/foo", // set auto exit node via prefs with an unknown/unsupported expression @@ -744,6 +751,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode1.StableID(), // unknown exit node expressions should work as "any" AutoExitNode: "foo", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-exit-node-via-prefs/off", // toggle the exit node off after it was set to "any" @@ -763,6 +771,7 @@ func TestConfigureExitNode(t *testing.T) { AutoExitNode: "", InternalExitNodePrior: "auto:any", }, + wantHostinfoExitNodeID: ptr.To(tailcfg.StableNodeID("")), }, { name: "auto-exit-node-via-prefs/on", // toggle the exit node on @@ -779,6 +788,7 @@ func TestConfigureExitNode(t *testing.T) { AutoExitNode: "any", InternalExitNodePrior: "auto:any", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "id-via-policy", // set exit node ID via syspolicy @@ -791,6 +801,7 @@ func TestConfigureExitNode(t *testing.T) { ControlURL: controlURL, ExitNodeID: exitNode1.StableID(), }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "id-via-policy/cannot-override-via-prefs/by-id", // syspolicy should take precedence over prefs @@ -809,7 +820,8 @@ func TestConfigureExitNode(t *testing.T) { ControlURL: controlURL, ExitNodeID: exitNode1.StableID(), }, - wantChangePrefsErr: errManagedByPolicy, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), + wantChangePrefsErr: errManagedByPolicy, }, { name: "id-via-policy/cannot-override-via-prefs/by-ip", // syspolicy should take precedence over prefs @@ -828,7 +840,8 @@ func TestConfigureExitNode(t *testing.T) { ControlURL: controlURL, ExitNodeID: exitNode1.StableID(), }, - wantChangePrefsErr: errManagedByPolicy, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), + wantChangePrefsErr: errManagedByPolicy, }, { name: "id-via-policy/cannot-override-via-prefs/by-auto-expr", // syspolicy should take precedence over prefs @@ -860,6 +873,7 @@ func TestConfigureExitNode(t *testing.T) { ControlURL: controlURL, ExitNodeID: exitNode2.StableID(), }, + wantHostinfoExitNodeID: ptr.To(exitNode2.StableID()), }, { name: "auto-any-via-policy", // set auto exit node via syspolicy (an exit node should be selected) @@ -874,6 +888,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode1.StableID(), AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-any-via-policy/no-report", // set auto exit node via syspolicy without a netcheck report (no exit node should be selected) @@ -888,6 +903,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: unresolvedExitNodeID, AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(unresolvedExitNodeID), }, { name: "auto-any-via-policy/no-netmap", // similarly, but without a netmap (no exit node should be selected) @@ -902,6 +918,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: unresolvedExitNodeID, AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(unresolvedExitNodeID), }, { name: "auto-any-via-policy/no-netmap/with-existing", // set auto exit node via syspolicy without a netmap, but with a previously set exit node ID @@ -918,6 +935,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode2.StableID(), AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(exitNode2.StableID()), }, { name: "auto-any-via-policy/no-netmap/with-allowed-existing", // same, but now with a syspolicy setting that explicitly allows the existing exit node ID @@ -936,6 +954,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode2.StableID(), AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(exitNode2.StableID()), }, { name: "auto-any-via-policy/no-netmap/with-disallowed-existing", // same, but now with a syspolicy setting that does not allow the existing exit node ID @@ -954,6 +973,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: unresolvedExitNodeID, // we don't have a netmap yet, and the current exit node ID is not allowed; block traffic AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(unresolvedExitNodeID), }, { name: "auto-any-via-policy/with-netmap/with-allowed-existing", // same, but now with a syspolicy setting that does not allow the existing exit node ID @@ -972,6 +992,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode2.StableID(), // we have a netmap; switch to the best allowed exit node AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(exitNode2.StableID()), }, { name: "auto-any-via-policy/with-netmap/switch-to-better", // if all exit nodes are allowed, switch to the best one once we have a netmap @@ -987,6 +1008,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode1.StableID(), // switch to the best exit node AutoExitNode: "any", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-foo-via-policy", // set auto exit node via syspolicy with an unknown/unsupported expression @@ -1001,6 +1023,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode1.StableID(), // unknown exit node expressions should work as "any" AutoExitNode: "foo", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-foo-via-edit-prefs", // set auto exit node via EditPrefs with an unknown/unsupported expression @@ -1018,6 +1041,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode1.StableID(), // unknown exit node expressions should work as "any" AutoExitNode: "foo", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-any-via-policy/toggle-off", // cannot toggle off the exit node if it was set via syspolicy @@ -1035,6 +1059,7 @@ func TestConfigureExitNode(t *testing.T) { AutoExitNode: "any", InternalExitNodePrior: "", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-any-via-policy/allow-override/change", // changing the exit node is allowed by [syspolicy.AllowExitNodeOverride] @@ -1056,6 +1081,7 @@ func TestConfigureExitNode(t *testing.T) { ExitNodeID: exitNode2.StableID(), // overridden by user AutoExitNode: "", // cleared, as we are setting the exit node ID explicitly }, + wantHostinfoExitNodeID: ptr.To(exitNode2.StableID()), }, { name: "auto-any-via-policy/allow-override/clear", // clearing the exit node ID is not allowed by [syspolicy.AllowExitNodeOverride] @@ -1079,6 +1105,7 @@ func TestConfigureExitNode(t *testing.T) { AutoExitNode: "any", InternalExitNodePrior: "", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-any-via-policy/allow-override/toggle-off", // similarly, toggling off the exit node is not allowed even with [syspolicy.AllowExitNodeOverride] @@ -1097,6 +1124,7 @@ func TestConfigureExitNode(t *testing.T) { AutoExitNode: "any", InternalExitNodePrior: "", }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, { name: "auto-any-via-initial-prefs/no-netmap/clear-auto-exit-node", @@ -1117,6 +1145,7 @@ func TestConfigureExitNode(t *testing.T) { AutoExitNode: "", // cleared ExitNodeID: "", // has never been resolved, so it should be cleared as well }, + wantHostinfoExitNodeID: ptr.To(tailcfg.StableNodeID("")), }, { name: "auto-any-via-initial-prefs/with-netmap/clear-auto-exit-node", @@ -1137,6 +1166,7 @@ func TestConfigureExitNode(t *testing.T) { AutoExitNode: "", // cleared ExitNodeID: exitNode1.StableID(), // a resolved exit node ID should be retained }, + wantHostinfoExitNodeID: ptr.To(exitNode1.StableID()), }, } syspolicy.RegisterWellKnownSettingsForTest(t) @@ -1197,6 +1227,13 @@ func TestConfigureExitNode(t *testing.T) { if diff := cmp.Diff(&tt.wantPrefs, lb.Prefs().AsStruct(), opts...); diff != "" { t.Errorf("Prefs(+got -want): %v", diff) } + + // And check Hostinfo. + if tt.wantHostinfoExitNodeID != nil { + if got := lb.hostinfo.ExitNodeID; got != *tt.wantHostinfoExitNodeID { + t.Errorf("Hostinfo.ExitNodeID got %v, want %v", got, *tt.wantHostinfoExitNodeID) + } + } }) } } diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 550914b96..307b39f93 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -166,7 +166,8 @@ type CapabilityVersion int // - 119: 2025-07-10: Client uses Hostinfo.Location.Priority to prioritize one route over another. // - 120: 2025-07-15: Client understands peer relay disco messages, and implements peer client and relay server functions // - 121: 2025-07-19: Client understands peer relay endpoint alloc with [disco.AllocateUDPRelayEndpointRequest] & [disco.AllocateUDPRelayEndpointResponse] -const CurrentCapabilityVersion CapabilityVersion = 121 +// - 122: 2025-07-21: Client sends Hostinfo.ExitNodeID to report which exit node it has selected, if any. +const CurrentCapabilityVersion CapabilityVersion = 122 // ID is an integer ID for a user, node, or login allocated by the // control plane. @@ -875,6 +876,7 @@ type Hostinfo struct { UserspaceRouter opt.Bool `json:",omitempty"` // if the client's subnet router is running in userspace (netstack) mode AppConnector opt.Bool `json:",omitempty"` // if the client is running the app-connector service ServicesHash string `json:",omitempty"` // opaque hash of the most recent list of tailnet services, change in hash indicates config should be fetched via c2n + ExitNodeID StableNodeID `json:",omitzero"` // the client’s selected exit node, empty when unselected. // Location represents geographical location data about a // Tailscale host. Location is optional and only set if diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 412e1f38d..95f8905b8 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -186,6 +186,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct { UserspaceRouter opt.Bool AppConnector opt.Bool ServicesHash string + ExitNodeID StableNodeID Location *Location TPM *TPMInfo StateEncrypted opt.Bool diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index 833314df8..addd2330b 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -67,6 +67,7 @@ func TestHostinfoEqual(t *testing.T) { "UserspaceRouter", "AppConnector", "ServicesHash", + "ExitNodeID", "Location", "TPM", "StateEncrypted", @@ -273,6 +274,21 @@ func TestHostinfoEqual(t *testing.T) { &Hostinfo{IngressEnabled: true}, false, }, + { + &Hostinfo{ExitNodeID: "stable-exit"}, + &Hostinfo{ExitNodeID: "stable-exit"}, + true, + }, + { + &Hostinfo{ExitNodeID: ""}, + &Hostinfo{}, + true, + }, + { + &Hostinfo{ExitNodeID: ""}, + &Hostinfo{ExitNodeID: "stable-exit"}, + false, + }, } for i, tt := range tests { got := tt.a.Equal(tt.b) diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 7e82cd871..c40780021 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -300,6 +300,7 @@ func (v HostinfoView) Userspace() opt.Bool { return v.ж.User func (v HostinfoView) UserspaceRouter() opt.Bool { return v.ж.UserspaceRouter } func (v HostinfoView) AppConnector() opt.Bool { return v.ж.AppConnector } func (v HostinfoView) ServicesHash() string { return v.ж.ServicesHash } +func (v HostinfoView) ExitNodeID() StableNodeID { return v.ж.ExitNodeID } func (v HostinfoView) Location() LocationView { return v.ж.Location.View() } func (v HostinfoView) TPM() views.ValuePointer[TPMInfo] { return views.ValuePointerOf(v.ж.TPM) } @@ -345,6 +346,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct { UserspaceRouter opt.Bool AppConnector opt.Bool ServicesHash string + ExitNodeID StableNodeID Location *Location TPM *TPMInfo StateEncrypted opt.Bool