mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-16 18:48:37 +00:00
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:
parent
2c630e126b
commit
740b77df59
@ -414,6 +414,19 @@ type LocalBackend struct {
|
||||
// reconnectTimer is used to schedule a reconnect by setting [ipn.Prefs.WantRunning]
|
||||
// to true after a delay, or nil if no reconnect is scheduled.
|
||||
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.
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -1957,7 +1971,7 @@ func (b *LocalBackend) reconcilePrefs() (_ ipn.PrefsView, anyChange bool) {
|
||||
// sysPolicyChanged is a callback triggered by syspolicy when it detects
|
||||
// a change in one or more syspolicy settings.
|
||||
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,
|
||||
// we should reset the overrideAlwaysOn flag, as the override might
|
||||
// no longer be valid.
|
||||
@ -1966,6 +1980,14 @@ func (b *LocalBackend) sysPolicyChanged(policy *rsop.PolicyChange) {
|
||||
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) {
|
||||
b.refreshAllowedSuggestions()
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
|
||||
if mp.RunSSHSet && mp.RunSSH && !envknob.CanSSHD() {
|
||||
@ -4342,15 +4364,19 @@ func (b *LocalBackend) checkEditPrefsAccessLocked(actor ipnauth.Actor, mp *ipn.M
|
||||
// Prevent users from changing exit node preferences
|
||||
// when exit node usage is managed by policy.
|
||||
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)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("policy check failed: %w", err)
|
||||
} else if isManaged {
|
||||
// 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 {
|
||||
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...)
|
||||
}
|
||||
|
||||
// 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,
|
||||
// such as zeroing out mutually exclusive fields.
|
||||
//
|
||||
// It must not assume that the changes in mp will actually be applied.
|
||||
//
|
||||
// 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.
|
||||
if mp.ExitNodeIDSet && mp.ExitNodeID == "" && !mp.InternalExitNodePriorSet {
|
||||
mp.InternalExitNodePrior = ""
|
||||
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
|
||||
// ExitNodeID or ExitNodeIP.
|
||||
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.
|
||||
// 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,
|
||||
@ -4486,15 +4579,17 @@ func (b *LocalBackend) stopReconnectTimerLocked() {
|
||||
func (b *LocalBackend) editPrefsLockedOnEntry(actor ipnauth.Actor, mp *ipn.MaskedPrefs, unlock unlockOnce) (ipn.PrefsView, error) {
|
||||
defer unlock() // for error paths
|
||||
|
||||
p0 := b.pm.CurrentPrefs()
|
||||
|
||||
// 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)
|
||||
return ipn.PrefsView{}, err
|
||||
}
|
||||
|
||||
// Apply additional changes to mp if necessary,
|
||||
// such as clearing mutually exclusive fields.
|
||||
b.adjustEditPrefsLocked(actor, mp)
|
||||
b.adjustEditPrefsLocked(p0, mp)
|
||||
|
||||
if mp.EggSet {
|
||||
mp.EggSet = false
|
||||
@ -4502,7 +4597,6 @@ func (b *LocalBackend) editPrefsLockedOnEntry(actor ipnauth.Actor, mp *ipn.Maske
|
||||
b.goTracker.Go(b.doSetHostinfoFilterServices)
|
||||
}
|
||||
|
||||
p0 := b.pm.CurrentPrefs()
|
||||
p1 := b.pm.CurrentPrefs().AsStruct()
|
||||
p1.ApplyEdits(mp)
|
||||
|
||||
@ -7231,6 +7325,7 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
|
||||
b.serveConfig = ipn.ServeConfigView{}
|
||||
b.lastSuggestedExitNode = ""
|
||||
b.keyExpired = false
|
||||
b.overrideExitNodePolicy = false
|
||||
b.resetAlwaysOnOverrideLocked()
|
||||
b.extHost.NotifyProfileChange(b.pm.CurrentProfile(), b.pm.CurrentPrefs(), false)
|
||||
b.setAtomicValuesFromPrefsLocked(b.pm.CurrentPrefs())
|
||||
|
@ -623,6 +623,7 @@ func TestConfigureExitNode(t *testing.T) {
|
||||
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]
|
||||
@ -1018,6 +1019,108 @@ func TestConfigureExitNode(t *testing.T) {
|
||||
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)
|
||||
for _, tt := range tests {
|
||||
@ -1033,6 +1136,9 @@ func TestConfigureExitNode(t *testing.T) {
|
||||
if tt.exitNodeAllowedIDs != nil {
|
||||
store.SetStringLists(source.TestSettingOf(syspolicy.AllowedSuggestedExitNodes, toStrings(tt.exitNodeAllowedIDs)))
|
||||
}
|
||||
if tt.exitNodeAllowOverride {
|
||||
store.SetBooleans(source.TestSettingOf(syspolicy.AllowExitNodeOverride, true))
|
||||
}
|
||||
if store.IsEmpty() {
|
||||
// No syspolicy settings, so don't register a store.
|
||||
// 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) {
|
||||
type interfacePrefix struct {
|
||||
i netmon.Interface
|
||||
|
@ -54,6 +54,15 @@ const (
|
||||
ExitNodeID Key = "ExitNodeID"
|
||||
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".
|
||||
// The default is "user-decides" unless otherwise stated. Enforcement of
|
||||
// these policies is typically performed in ipnlocal.applySysPolicy(). GUIs
|
||||
@ -173,6 +182,7 @@ const (
|
||||
var implicitDefinitions = []*setting.Definition{
|
||||
// Device policy settings (can only be configured on a per-device basis):
|
||||
setting.NewDefinition(AllowedSuggestedExitNodes, setting.DeviceSetting, setting.StringListValue),
|
||||
setting.NewDefinition(AllowExitNodeOverride, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(AlwaysOn, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(AlwaysOnOverrideWithReason, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),
|
||||
|
@ -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.
|
||||
// It is safe for concurrent use.
|
||||
type policyChangeCallbacks struct {
|
||||
|
Loading…
x
Reference in New Issue
Block a user