ipn/ipnlocal,util/syspolicy: add support for ExitNode.AllowOverride policy setting

When the policy setting is enabled, it allows users to override the exit node enforced by the ExitNodeID
or ExitNodeIP policy. It's primarily intended for use when ExitNodeID is set to auto:any, but it can also
be used with specific exit nodes. It does not allow disabling exit node usage entirely.

Once the exit node policy is overridden, it will not be enforced again until the policy changes,
the user connects or disconnects Tailscale, switches profiles, or disables the override.

Updates tailscale/corp#29969

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-07-08 16:08:28 -05:00 committed by Nick Khyl
parent 2c630e126b
commit 740b77df59
4 changed files with 434 additions and 12 deletions

View File

@ -414,6 +414,19 @@ type LocalBackend struct {
// reconnectTimer is used to schedule a reconnect by setting [ipn.Prefs.WantRunning] // reconnectTimer is used to schedule a reconnect by setting [ipn.Prefs.WantRunning]
// to true after a delay, or nil if no reconnect is scheduled. // to true after a delay, or nil if no reconnect is scheduled.
reconnectTimer tstime.TimerController reconnectTimer tstime.TimerController
// overrideExitNodePolicy is whether the user has overridden the exit node policy
// by manually selecting an exit node, as allowed by [syspolicy.AllowExitNodeOverride].
//
// If true, the [syspolicy.ExitNodeID] and [syspolicy.ExitNodeIP] policy settings are ignored,
// and the suggested exit node is not applied automatically.
//
// It is cleared when the user switches back to the state required by policy (typically, auto:any),
// or when switching profiles, connecting/disconnecting Tailscale, restarting the client,
// or on similar events.
//
// See tailscale/corp#29969.
overrideExitNodePolicy bool
} }
// HealthTracker returns the health tracker for the backend. // HealthTracker returns the health tracker for the backend.
@ -1841,7 +1854,8 @@ func (b *LocalBackend) applySysPolicyLocked(prefs *ipn.Prefs) (anyChange bool) {
} }
} }
if b.applyExitNodeSysPolicyLocked(prefs) { // Only apply the exit node policy if the user hasn't overridden it.
if !b.overrideExitNodePolicy && b.applyExitNodeSysPolicyLocked(prefs) {
anyChange = true anyChange = true
} }
@ -1957,7 +1971,7 @@ func (b *LocalBackend) reconcilePrefs() (_ ipn.PrefsView, anyChange bool) {
// sysPolicyChanged is a callback triggered by syspolicy when it detects // sysPolicyChanged is a callback triggered by syspolicy when it detects
// a change in one or more syspolicy settings. // a change in one or more syspolicy settings.
func (b *LocalBackend) sysPolicyChanged(policy *rsop.PolicyChange) { func (b *LocalBackend) sysPolicyChanged(policy *rsop.PolicyChange) {
if policy.HasChanged(syspolicy.AlwaysOn) || policy.HasChanged(syspolicy.AlwaysOnOverrideWithReason) { if policy.HasChangedAnyOf(syspolicy.AlwaysOn, syspolicy.AlwaysOnOverrideWithReason) {
// If the AlwaysOn or the AlwaysOnOverrideWithReason policy has changed, // If the AlwaysOn or the AlwaysOnOverrideWithReason policy has changed,
// we should reset the overrideAlwaysOn flag, as the override might // we should reset the overrideAlwaysOn flag, as the override might
// no longer be valid. // no longer be valid.
@ -1966,6 +1980,14 @@ func (b *LocalBackend) sysPolicyChanged(policy *rsop.PolicyChange) {
b.mu.Unlock() b.mu.Unlock()
} }
if policy.HasChangedAnyOf(syspolicy.ExitNodeID, syspolicy.ExitNodeIP, syspolicy.AllowExitNodeOverride) {
// Reset the exit node override if a policy that enforces exit node usage
// or allows the user to override automatic exit node selection has changed.
b.mu.Lock()
b.overrideExitNodePolicy = false
b.mu.Unlock()
}
if policy.HasChanged(syspolicy.AllowedSuggestedExitNodes) { if policy.HasChanged(syspolicy.AllowedSuggestedExitNodes) {
b.refreshAllowedSuggestions() b.refreshAllowedSuggestions()
// Re-evaluate exit node suggestion now that the policy setting has changed. // Re-evaluate exit node suggestion now that the policy setting has changed.
@ -4320,12 +4342,12 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip
} }
// checkEditPrefsAccessLocked checks whether the current user has access // checkEditPrefsAccessLocked checks whether the current user has access
// to apply the prefs changes in mp. // to apply the changes in mp to the given prefs.
// //
// It returns an error if the user is not allowed, or nil otherwise. // It returns an error if the user is not allowed, or nil otherwise.
// //
// b.mu must be held. // b.mu must be held.
func (b *LocalBackend) checkEditPrefsAccessLocked(actor ipnauth.Actor, mp *ipn.MaskedPrefs) error { func (b *LocalBackend) checkEditPrefsAccessLocked(actor ipnauth.Actor, prefs ipn.PrefsView, mp *ipn.MaskedPrefs) error {
var errs []error var errs []error
if mp.RunSSHSet && mp.RunSSH && !envknob.CanSSHD() { if mp.RunSSHSet && mp.RunSSH && !envknob.CanSSHD() {
@ -4342,14 +4364,18 @@ func (b *LocalBackend) checkEditPrefsAccessLocked(actor ipnauth.Actor, mp *ipn.M
// Prevent users from changing exit node preferences // Prevent users from changing exit node preferences
// when exit node usage is managed by policy. // when exit node usage is managed by policy.
if mp.ExitNodeIDSet || mp.ExitNodeIPSet || mp.AutoExitNodeSet { if mp.ExitNodeIDSet || mp.ExitNodeIPSet || mp.AutoExitNodeSet {
// TODO(nickkhyl): Allow users to override ExitNode policy settings
// if the ExitNode.AllowUserOverride policy permits it.
// (Policy setting name and details are TBD. See tailscale/corp#29969)
isManaged, err := syspolicy.HasAnyOf(syspolicy.ExitNodeID, syspolicy.ExitNodeIP) isManaged, err := syspolicy.HasAnyOf(syspolicy.ExitNodeID, syspolicy.ExitNodeIP)
if err != nil { if err != nil {
err = fmt.Errorf("policy check failed: %w", err) err = fmt.Errorf("policy check failed: %w", err)
} else if isManaged { } else if isManaged {
err = errManagedByPolicy // Allow users to override ExitNode policy settings and select an exit node manually
// if permitted by [syspolicy.AllowExitNodeOverride].
//
// Disabling exit node usage entirely is not allowed.
allowExitNodeOverride, _ := syspolicy.GetBoolean(syspolicy.AllowExitNodeOverride, false)
if !allowExitNodeOverride || b.changeDisablesExitNodeLocked(prefs, mp) {
err = errManagedByPolicy
}
} }
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("exit node cannot be changed: %w", err)) errs = append(errs, fmt.Errorf("exit node cannot be changed: %w", err))
@ -4359,19 +4385,70 @@ func (b *LocalBackend) checkEditPrefsAccessLocked(actor ipnauth.Actor, mp *ipn.M
return multierr.New(errs...) return multierr.New(errs...)
} }
// changeDisablesExitNodeLocked reports whether applying the change
// to the given prefs would disable exit node usage.
//
// In other words, it returns true if prefs.ExitNodeID is non-empty
// initially, but would become empty after applying the given change.
//
// It applies the same adjustments and resolves the exit node in the prefs
// as done during actual edits. While not optimal performance-wise,
// changing the exit node via LocalAPI isn't a hot path, and reusing
// the same logic ensures consistency and simplifies maintenance.
//
// b.mu must be held.
func (b *LocalBackend) changeDisablesExitNodeLocked(prefs ipn.PrefsView, change *ipn.MaskedPrefs) bool {
if !change.AutoExitNodeSet && !change.ExitNodeIDSet && !change.ExitNodeIPSet {
// The change does not affect exit node usage.
return false
}
if prefs.ExitNodeID() == "" {
// Exit node usage is already disabled.
// Note that we do not check for ExitNodeIP here.
// If ExitNodeIP hasn't been resolved to a node,
// it's not enabled yet.
return false
}
// First, apply the adjustments to a copy of the changes,
// e.g., clear AutoExitNode if ExitNodeID is set.
tmpChange := ptr.To(*change)
tmpChange.Prefs = *change.Prefs.Clone()
b.adjustEditPrefsLocked(prefs, tmpChange)
// Then apply the adjusted changes to a copy of the current prefs,
// and resolve the exit node in the prefs.
tmpPrefs := prefs.AsStruct()
tmpPrefs.ApplyEdits(tmpChange)
b.resolveExitNodeInPrefsLocked(tmpPrefs)
// If ExitNodeID is empty after applying the changes,
// but wasn't empty before, then the change disables
// exit node usage.
return tmpPrefs.ExitNodeID == ""
}
// adjustEditPrefsLocked applies additional changes to mp if necessary, // adjustEditPrefsLocked applies additional changes to mp if necessary,
// such as zeroing out mutually exclusive fields. // such as zeroing out mutually exclusive fields.
// //
// It must not assume that the changes in mp will actually be applied. // It must not assume that the changes in mp will actually be applied.
// //
// b.mu must be held. // b.mu must be held.
func (b *LocalBackend) adjustEditPrefsLocked(_ ipnauth.Actor, mp *ipn.MaskedPrefs) { func (b *LocalBackend) adjustEditPrefsLocked(prefs ipn.PrefsView, mp *ipn.MaskedPrefs) {
// Zeroing the ExitNodeID via localAPI must also zero the prior exit node. // Zeroing the ExitNodeID via localAPI must also zero the prior exit node.
if mp.ExitNodeIDSet && mp.ExitNodeID == "" && !mp.InternalExitNodePriorSet { if mp.ExitNodeIDSet && mp.ExitNodeID == "" && !mp.InternalExitNodePriorSet {
mp.InternalExitNodePrior = "" mp.InternalExitNodePrior = ""
mp.InternalExitNodePriorSet = true mp.InternalExitNodePriorSet = true
} }
// Clear ExitNodeID if AutoExitNode is disabled and ExitNodeID is still unresolved.
if mp.AutoExitNodeSet && mp.AutoExitNode == "" && prefs.ExitNodeID() == unresolvedExitNodeID {
mp.ExitNodeIDSet = true
mp.ExitNodeID = ""
}
// Disable automatic exit node selection if the user explicitly sets // Disable automatic exit node selection if the user explicitly sets
// ExitNodeID or ExitNodeIP. // ExitNodeID or ExitNodeIP.
if (mp.ExitNodeIDSet || mp.ExitNodeIPSet) && !mp.AutoExitNodeSet { if (mp.ExitNodeIDSet || mp.ExitNodeIPSet) && !mp.AutoExitNodeSet {
@ -4404,6 +4481,22 @@ func (b *LocalBackend) onEditPrefsLocked(_ ipnauth.Actor, mp *ipn.MaskedPrefs, o
} }
} }
if oldPrefs.WantRunning() != newPrefs.WantRunning() {
// Connecting to or disconnecting from Tailscale clears the override,
// unless the user is also explicitly changing the exit node (see below).
b.overrideExitNodePolicy = false
}
if mp.AutoExitNodeSet || mp.ExitNodeIDSet || mp.ExitNodeIPSet {
if allowExitNodeOverride, _ := syspolicy.GetBoolean(syspolicy.AllowExitNodeOverride, false); allowExitNodeOverride {
// If applying exit node policy settings to the new prefs results in no change,
// the user is not overriding the policy. Otherwise, it is an override.
b.overrideExitNodePolicy = b.applyExitNodeSysPolicyLocked(newPrefs.AsStruct())
} else {
// Overrides are not allowed; clear the override flag.
b.overrideExitNodePolicy = false
}
}
// This is recorded here in the EditPrefs path, not the setPrefs path on purpose. // This is recorded here in the EditPrefs path, not the setPrefs path on purpose.
// recordForEdit records metrics related to edits and changes, not the final state. // recordForEdit records metrics related to edits and changes, not the final state.
// If, in the future, we want to record gauge-metrics related to the state of prefs, // If, in the future, we want to record gauge-metrics related to the state of prefs,
@ -4486,15 +4579,17 @@ func (b *LocalBackend) stopReconnectTimerLocked() {
func (b *LocalBackend) editPrefsLockedOnEntry(actor ipnauth.Actor, mp *ipn.MaskedPrefs, unlock unlockOnce) (ipn.PrefsView, error) { func (b *LocalBackend) editPrefsLockedOnEntry(actor ipnauth.Actor, mp *ipn.MaskedPrefs, unlock unlockOnce) (ipn.PrefsView, error) {
defer unlock() // for error paths defer unlock() // for error paths
p0 := b.pm.CurrentPrefs()
// Check if the changes in mp are allowed. // Check if the changes in mp are allowed.
if err := b.checkEditPrefsAccessLocked(actor, mp); err != nil { if err := b.checkEditPrefsAccessLocked(actor, p0, mp); err != nil {
b.logf("EditPrefs(%v): %v", mp.Pretty(), err) b.logf("EditPrefs(%v): %v", mp.Pretty(), err)
return ipn.PrefsView{}, err return ipn.PrefsView{}, err
} }
// Apply additional changes to mp if necessary, // Apply additional changes to mp if necessary,
// such as clearing mutually exclusive fields. // such as clearing mutually exclusive fields.
b.adjustEditPrefsLocked(actor, mp) b.adjustEditPrefsLocked(p0, mp)
if mp.EggSet { if mp.EggSet {
mp.EggSet = false mp.EggSet = false
@ -4502,7 +4597,6 @@ func (b *LocalBackend) editPrefsLockedOnEntry(actor ipnauth.Actor, mp *ipn.Maske
b.goTracker.Go(b.doSetHostinfoFilterServices) b.goTracker.Go(b.doSetHostinfoFilterServices)
} }
p0 := b.pm.CurrentPrefs()
p1 := b.pm.CurrentPrefs().AsStruct() p1 := b.pm.CurrentPrefs().AsStruct()
p1.ApplyEdits(mp) p1.ApplyEdits(mp)
@ -7231,6 +7325,7 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
b.serveConfig = ipn.ServeConfigView{} b.serveConfig = ipn.ServeConfigView{}
b.lastSuggestedExitNode = "" b.lastSuggestedExitNode = ""
b.keyExpired = false b.keyExpired = false
b.overrideExitNodePolicy = false
b.resetAlwaysOnOverrideLocked() b.resetAlwaysOnOverrideLocked()
b.extHost.NotifyProfileChange(b.pm.CurrentProfile(), b.pm.CurrentPrefs(), false) b.extHost.NotifyProfileChange(b.pm.CurrentProfile(), b.pm.CurrentPrefs(), false)
b.setAtomicValuesFromPrefsLocked(b.pm.CurrentPrefs()) b.setAtomicValuesFromPrefsLocked(b.pm.CurrentPrefs())

View File

@ -623,6 +623,7 @@ func TestConfigureExitNode(t *testing.T) {
exitNodeIDPolicy *tailcfg.StableNodeID exitNodeIDPolicy *tailcfg.StableNodeID
exitNodeIPPolicy *netip.Addr exitNodeIPPolicy *netip.Addr
exitNodeAllowedIDs []tailcfg.StableNodeID // nil if all IDs are allowed for auto exit nodes 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] wantChangePrefsErr error // if non-nil, the error we expect from [LocalBackend.EditPrefsAs]
wantPrefs ipn.Prefs wantPrefs ipn.Prefs
wantExitNodeToggleErr error // if non-nil, the error we expect from [LocalBackend.SetUseExitNodeEnabled] wantExitNodeToggleErr error // if non-nil, the error we expect from [LocalBackend.SetUseExitNodeEnabled]
@ -1018,6 +1019,108 @@ func TestConfigureExitNode(t *testing.T) {
InternalExitNodePrior: "", InternalExitNodePrior: "",
}, },
}, },
{
name: "auto-any-via-policy/allow-override/change", // changing the exit node is allowed by [syspolicy.AllowExitNodeOverride]
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
exitNodeAllowOverride: true, // allow changing the exit node
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeID: exitNode2.StableID(), // change the exit node ID
},
ExitNodeIDSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode2.StableID(), // overridden by user
AutoExitNode: "", // cleared, as we are setting the exit node ID explicitly
},
},
{
name: "auto-any-via-policy/allow-override/clear", // clearing the exit node ID is not allowed by [syspolicy.AllowExitNodeOverride]
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
exitNodeAllowOverride: true, // allow changing, but not disabling, the exit node
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeID: "", // clearing the exit node ID disables the exit node and should not be allowed
},
ExitNodeIDSet: true,
},
wantChangePrefsErr: errManagedByPolicy, // edit prefs should fail with an error
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(), // still enforced by the policy setting
AutoExitNode: "any",
InternalExitNodePrior: "",
},
},
{
name: "auto-any-via-policy/allow-override/toggle-off", // similarly, toggling off the exit node is not allowed even with [syspolicy.AllowExitNodeOverride]
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
exitNodeAllowOverride: true, // allow changing, but not disabling, the exit node
useExitNodeEnabled: ptr.To(false), // should fail with an error
wantExitNodeToggleErr: errManagedByPolicy,
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(), // still enforced by the policy setting
AutoExitNode: "any",
InternalExitNodePrior: "",
},
},
{
name: "auto-any-via-initial-prefs/no-netmap/clear-auto-exit-node",
prefs: ipn.Prefs{
ControlURL: controlURL,
AutoExitNode: ipn.AnyExitNode,
},
netMap: nil, // no netmap; exit node cannot be resolved
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AutoExitNode: "", // clear the auto exit node
},
AutoExitNodeSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
AutoExitNode: "", // cleared
ExitNodeID: "", // has never been resolved, so it should be cleared as well
},
},
{
name: "auto-any-via-initial-prefs/with-netmap/clear-auto-exit-node",
prefs: ipn.Prefs{
ControlURL: controlURL,
AutoExitNode: ipn.AnyExitNode,
},
netMap: clientNetmap, // has a netmap; exit node will be resolved
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AutoExitNode: "", // clear the auto exit node
},
AutoExitNodeSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
AutoExitNode: "", // cleared
ExitNodeID: exitNode1.StableID(), // a resolved exit node ID should be retained
},
},
} }
syspolicy.RegisterWellKnownSettingsForTest(t) syspolicy.RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
@ -1033,6 +1136,9 @@ func TestConfigureExitNode(t *testing.T) {
if tt.exitNodeAllowedIDs != nil { if tt.exitNodeAllowedIDs != nil {
store.SetStringLists(source.TestSettingOf(syspolicy.AllowedSuggestedExitNodes, toStrings(tt.exitNodeAllowedIDs))) store.SetStringLists(source.TestSettingOf(syspolicy.AllowedSuggestedExitNodes, toStrings(tt.exitNodeAllowedIDs)))
} }
if tt.exitNodeAllowOverride {
store.SetBooleans(source.TestSettingOf(syspolicy.AllowExitNodeOverride, true))
}
if store.IsEmpty() { if store.IsEmpty() {
// No syspolicy settings, so don't register a store. // No syspolicy settings, so don't register a store.
// This allows the test to run in parallel with other tests. // This allows the test to run in parallel with other tests.
@ -1078,6 +1184,212 @@ func TestConfigureExitNode(t *testing.T) {
} }
} }
func TestPrefsChangeDisablesExitNode(t *testing.T) {
tests := []struct {
name string
netMap *netmap.NetworkMap
prefs ipn.Prefs
change ipn.MaskedPrefs
wantDisablesExitNode bool
}{
{
name: "has-exit-node-id/no-change",
prefs: ipn.Prefs{
ExitNodeID: "test-exit-node",
},
change: ipn.MaskedPrefs{},
wantDisablesExitNode: false,
},
{
name: "has-exit-node-ip/no-change",
prefs: ipn.Prefs{
ExitNodeIP: netip.MustParseAddr("100.100.1.1"),
},
change: ipn.MaskedPrefs{},
wantDisablesExitNode: false,
},
{
name: "has-auto-exit-node/no-change",
prefs: ipn.Prefs{
AutoExitNode: ipn.AnyExitNode,
},
change: ipn.MaskedPrefs{},
wantDisablesExitNode: false,
},
{
name: "has-exit-node-id/non-exit-node-change",
prefs: ipn.Prefs{
ExitNodeID: "test-exit-node",
},
change: ipn.MaskedPrefs{
WantRunningSet: true,
HostnameSet: true,
ExitNodeAllowLANAccessSet: true,
Prefs: ipn.Prefs{
WantRunning: true,
Hostname: "test-hostname",
ExitNodeAllowLANAccess: true,
},
},
wantDisablesExitNode: false,
},
{
name: "has-exit-node-ip/non-exit-node-change",
prefs: ipn.Prefs{
ExitNodeIP: netip.MustParseAddr("100.100.1.1"),
},
change: ipn.MaskedPrefs{
WantRunningSet: true,
RouteAllSet: true,
ShieldsUpSet: true,
Prefs: ipn.Prefs{
WantRunning: false,
RouteAll: false,
ShieldsUp: true,
},
},
wantDisablesExitNode: false,
},
{
name: "has-auto-exit-node/non-exit-node-change",
prefs: ipn.Prefs{
AutoExitNode: ipn.AnyExitNode,
},
change: ipn.MaskedPrefs{
CorpDNSSet: true,
RouteAllSet: true,
ExitNodeAllowLANAccessSet: true,
Prefs: ipn.Prefs{
CorpDNS: true,
RouteAll: false,
ExitNodeAllowLANAccess: true,
},
},
wantDisablesExitNode: false,
},
{
name: "has-exit-node-id/change-exit-node-id",
prefs: ipn.Prefs{
ExitNodeID: "exit-node-1",
},
change: ipn.MaskedPrefs{
ExitNodeIDSet: true,
Prefs: ipn.Prefs{
ExitNodeID: "exit-node-2",
},
},
wantDisablesExitNode: false, // changing the exit node ID does not disable it
},
{
name: "has-exit-node-id/enable-auto-exit-node",
prefs: ipn.Prefs{
ExitNodeID: "exit-node-1",
},
change: ipn.MaskedPrefs{
AutoExitNodeSet: true,
Prefs: ipn.Prefs{
AutoExitNode: ipn.AnyExitNode,
},
},
wantDisablesExitNode: false, // changing the exit node ID does not disable it
},
{
name: "has-exit-node-id/clear-exit-node-id",
prefs: ipn.Prefs{
ExitNodeID: "exit-node-1",
},
change: ipn.MaskedPrefs{
ExitNodeIDSet: true,
Prefs: ipn.Prefs{
ExitNodeID: "",
},
},
wantDisablesExitNode: true, // clearing the exit node ID disables it
},
{
name: "has-auto-exit-node/clear-exit-node-id",
prefs: ipn.Prefs{
AutoExitNode: ipn.AnyExitNode,
},
change: ipn.MaskedPrefs{
ExitNodeIDSet: true,
Prefs: ipn.Prefs{
ExitNodeID: "",
},
},
wantDisablesExitNode: true, // clearing the exit node ID disables auto exit node as well...
},
{
name: "has-auto-exit-node/clear-exit-node-id/but-keep-auto-exit-node",
prefs: ipn.Prefs{
AutoExitNode: ipn.AnyExitNode,
},
change: ipn.MaskedPrefs{
ExitNodeIDSet: true,
AutoExitNodeSet: true,
Prefs: ipn.Prefs{
ExitNodeID: "",
AutoExitNode: ipn.AnyExitNode,
},
},
wantDisablesExitNode: false, // ... unless we explicitly keep the auto exit node enabled
},
{
name: "has-auto-exit-node/clear-exit-node-ip",
prefs: ipn.Prefs{
AutoExitNode: ipn.AnyExitNode,
},
change: ipn.MaskedPrefs{
ExitNodeIPSet: true,
Prefs: ipn.Prefs{
ExitNodeIP: netip.Addr{},
},
},
wantDisablesExitNode: false, // auto exit node is still enabled
},
{
name: "has-auto-exit-node/clear-auto-exit-node",
prefs: ipn.Prefs{
AutoExitNode: ipn.AnyExitNode,
},
change: ipn.MaskedPrefs{
AutoExitNodeSet: true,
Prefs: ipn.Prefs{
AutoExitNode: "",
},
},
wantDisablesExitNode: true, // clearing the auto exit while the exit node ID is unresolved disables exit node usage
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lb := newTestLocalBackend(t)
if tt.netMap != nil {
lb.SetControlClientStatus(lb.cc, controlclient.Status{NetMap: tt.netMap})
}
// Set the initial prefs via SetPrefsForTest
// to apply necessary adjustments.
lb.SetPrefsForTest(tt.prefs.Clone())
initialPrefs := lb.Prefs()
// Check whether changeDisablesExitNodeLocked correctly identifies the change.
if got := lb.changeDisablesExitNodeLocked(initialPrefs, &tt.change); got != tt.wantDisablesExitNode {
t.Errorf("disablesExitNode: got %v; want %v", got, tt.wantDisablesExitNode)
}
// Apply the change and check if it the actual behavior matches the expectation.
gotPrefs, err := lb.EditPrefsAs(&tt.change, &ipnauth.TestActor{})
if err != nil {
t.Fatalf("EditPrefsAs failed: %v", err)
}
gotDisabledExitNode := initialPrefs.ExitNodeID() != "" && gotPrefs.ExitNodeID() == ""
if gotDisabledExitNode != tt.wantDisablesExitNode {
t.Errorf("disabledExitNode: got %v; want %v", gotDisabledExitNode, tt.wantDisablesExitNode)
}
})
}
}
func TestInternalAndExternalInterfaces(t *testing.T) { func TestInternalAndExternalInterfaces(t *testing.T) {
type interfacePrefix struct { type interfacePrefix struct {
i netmon.Interface i netmon.Interface

View File

@ -54,6 +54,15 @@ const (
ExitNodeID Key = "ExitNodeID" ExitNodeID Key = "ExitNodeID"
ExitNodeIP Key = "ExitNodeIP" // default ""; if blank, no exit node is forced. Value is exit node IP. ExitNodeIP Key = "ExitNodeIP" // default ""; if blank, no exit node is forced. Value is exit node IP.
// AllowExitNodeOverride is a boolean key that allows the user to override exit node policy settings
// and manually select an exit node. It does not allow disabling exit node usage entirely.
// It is typically used in conjunction with [ExitNodeID] set to "auto:any".
//
// Warning: This policy setting is experimental and may change, be renamed or removed in the future.
// It may also not be fully supported by all Tailscale clients until it is out of experimental status.
// See tailscale/corp#29969.
AllowExitNodeOverride Key = "ExitNode.AllowOverride"
// 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. Enforcement of // The default is "user-decides" unless otherwise stated. Enforcement of
// these policies is typically performed in ipnlocal.applySysPolicy(). GUIs // these policies is typically performed in ipnlocal.applySysPolicy(). GUIs
@ -173,6 +182,7 @@ const (
var implicitDefinitions = []*setting.Definition{ var implicitDefinitions = []*setting.Definition{
// Device policy settings (can only be configured on a per-device basis): // Device policy settings (can only be configured on a per-device basis):
setting.NewDefinition(AllowedSuggestedExitNodes, setting.DeviceSetting, setting.StringListValue), setting.NewDefinition(AllowedSuggestedExitNodes, setting.DeviceSetting, setting.StringListValue),
setting.NewDefinition(AllowExitNodeOverride, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(AlwaysOn, setting.DeviceSetting, setting.BooleanValue), setting.NewDefinition(AlwaysOn, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(AlwaysOnOverrideWithReason, setting.DeviceSetting, setting.BooleanValue), setting.NewDefinition(AlwaysOnOverrideWithReason, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue), setting.NewDefinition(ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),

View File

@ -59,6 +59,11 @@ func (c PolicyChange) HasChanged(key setting.Key) bool {
} }
} }
// HasChangedAnyOf reports whether any of the specified policy settings has changed.
func (c PolicyChange) HasChangedAnyOf(keys ...setting.Key) bool {
return slices.ContainsFunc(keys, c.HasChanged)
}
// policyChangeCallbacks are the callbacks to invoke when the effective policy changes. // policyChangeCallbacks are the callbacks to invoke when the effective policy changes.
// It is safe for concurrent use. // It is safe for concurrent use.
type policyChangeCallbacks struct { type policyChangeCallbacks struct {