cmd/tailscale/cli,ipn,ipn/ipnlocal: add AutoExitNode preference for automatic exit node selection

With this change, policy enforcement and exit node resolution can happen in separate steps,
since enforcement no longer depends on resolving the suggested exit node. This keeps policy
enforcement synchronous (e.g., when switching profiles), while allowing exit node resolution
to be asynchronous on netmap updates, link changes, etc.

Additionally, the new preference will be used to let GUIs and CLIs switch back to "auto" mode
after a manual exit node override, which is necessary for tailscale/corp#29969.

Updates tailscale/corp#29969
Updates #16459

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-07-03 12:21:29 -05:00 committed by Nick Khyl
parent 0098822981
commit a8055b5f40
8 changed files with 791 additions and 91 deletions

View File

@ -971,6 +971,10 @@ func TestPrefFlagMapping(t *testing.T) {
// Used internally by LocalBackend as part of exit node usage toggling.
// No CLI flag for this.
continue
case "AutoExitNode":
// TODO(nickkhyl): should be handled by tailscale {set,up} --exit-node.
// See tailscale/tailscale#16459.
continue
}
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
}

View File

@ -74,6 +74,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
RouteAll bool
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr
AutoExitNode ExitNodeExpression
InternalExitNodePrior tailcfg.StableNodeID
ExitNodeAllowLANAccess bool
CorpDNS bool

View File

@ -135,6 +135,7 @@ func (v PrefsView) ControlURL() string { return v.ж.Co
func (v PrefsView) RouteAll() bool { return v.ж.RouteAll }
func (v PrefsView) ExitNodeID() tailcfg.StableNodeID { return v.ж.ExitNodeID }
func (v PrefsView) ExitNodeIP() netip.Addr { return v.ж.ExitNodeIP }
func (v PrefsView) AutoExitNode() ExitNodeExpression { return v.ж.AutoExitNode }
func (v PrefsView) InternalExitNodePrior() tailcfg.StableNodeID { return v.ж.InternalExitNodePrior }
func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess }
func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS }
@ -179,6 +180,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
RouteAll bool
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr
AutoExitNode ExitNodeExpression
InternalExitNodePrior tailcfg.StableNodeID
ExitNodeAllowLANAccess bool
CorpDNS bool

View File

@ -912,13 +912,14 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
hadPAC := b.prevIfState.HasPAC()
b.prevIfState = ifst
b.pauseOrResumeControlClientLocked()
if delta.Major && shouldAutoExitNode() {
prefs := b.pm.CurrentPrefs()
if delta.Major && prefs.AutoExitNode().IsSet() {
b.refreshAutoExitNode = true
}
var needReconfig bool
// If the network changed and we're using an exit node and allowing LAN access, we may need to reconfigure.
if delta.Major && b.pm.CurrentPrefs().ExitNodeID() != "" && b.pm.CurrentPrefs().ExitNodeAllowLANAccess() {
if delta.Major && prefs.ExitNodeID() != "" && prefs.ExitNodeAllowLANAccess() {
b.logf("linkChange: in state %v; updating LAN routes", b.state)
needReconfig = true
}
@ -941,8 +942,8 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
// If the local network configuration has changed, our filter may
// need updating to tweak default routes.
b.updateFilterLocked(b.pm.CurrentPrefs())
updateExitNodeUsageWarning(b.pm.CurrentPrefs(), delta.New, b.health)
b.updateFilterLocked(prefs)
updateExitNodeUsageWarning(prefs, delta.New, b.health)
cn := b.currentNode()
nm := cn.NetMap()
@ -1623,17 +1624,17 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
prefsChanged = true
}
}
if shouldAutoExitNode() {
if applySysPolicy(prefs, b.overrideAlwaysOn) {
prefsChanged = true
}
if prefs.AutoExitNode.IsSet() {
// Re-evaluate exit node suggestion in case circumstances have changed.
_, err := b.suggestExitNodeLocked(curNetMap)
if err != nil && !errors.Is(err, ErrNoPreferredDERP) {
b.logf("SetControlClientStatus failed to select auto exit node: %v", err)
}
}
if applySysPolicy(prefs, b.lastSuggestedExitNode, b.overrideAlwaysOn) {
prefsChanged = true
}
if setExitNodeID(prefs, curNetMap) {
if setExitNodeID(prefs, b.lastSuggestedExitNode, curNetMap) {
prefsChanged = true
}
@ -1800,7 +1801,7 @@ var preferencePolicies = []preferencePolicyInfo{
// applySysPolicy overwrites configured preferences with policies that may be
// configured by the system administrator in an OS-specific way.
func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID, overrideAlwaysOn bool) (anyChange bool) {
func applySysPolicy(prefs *ipn.Prefs, overrideAlwaysOn bool) (anyChange bool) {
if controlURL, err := syspolicy.GetString(syspolicy.ControlURL, prefs.ControlURL); err == nil && prefs.ControlURL != controlURL {
prefs.ControlURL = controlURL
anyChange = true
@ -1839,21 +1840,51 @@ func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
if shouldAutoExitNode() && lastSuggestedExitNode != "" {
exitNodeID = lastSuggestedExitNode
}
// Note: when exitNodeIDStr == "auto" && lastSuggestedExitNode == "",
// then exitNodeID is now "auto" which will never match a peer's node ID.
// When there is no a peer matching the node ID, traffic will blackhole,
// preventing accidental non-exit-node usage when a policy is in effect that requires an exit node.
if prefs.ExitNodeID != exitNodeID || prefs.ExitNodeIP.IsValid() {
// Try to parse the policy setting value as an "auto:"-prefixed [ipn.ExitNodeExpression],
// and update prefs if it differs from the current one.
// This includes cases where it was previously an expression but no longer is,
// or where it wasn't before but now is.
autoExitNode, useAutoExitNode := parseAutoExitNodeID(exitNodeID)
if prefs.AutoExitNode != autoExitNode {
prefs.AutoExitNode = autoExitNode
anyChange = true
}
// Additionally, if the specified exit node ID is an expression,
// meaning an exit node is required but we don't yet have a valid exit node ID,
// we should set exitNodeID to a value that is never a valid [tailcfg.StableNodeID],
// to install a blackhole route and prevent accidental non-exit-node usage
// until the expression is evaluated and an actual exit node is selected.
// We use "auto:any" for this purpose, primarily for compatibility with
// older clients (in case a user downgrades to an earlier version)
// and GUIs/CLIs that have special handling for it.
if useAutoExitNode {
exitNodeID = unresolvedExitNodeID
}
// If the current exit node ID doesn't match the one enforced by the policy setting,
// and the policy either requires a specific exit node ID,
// or requires an auto exit node ID and the current one isn't allowed,
// then update the exit node ID.
if prefs.ExitNodeID != exitNodeID {
if !useAutoExitNode || !isAllowedAutoExitNodeID(prefs.ExitNodeID) {
prefs.ExitNodeID = exitNodeID
anyChange = true
}
}
// If the exit node IP is set, clear it. When ExitNodeIP is set in the prefs,
// it takes precedence over the ExitNodeID.
if prefs.ExitNodeIP.IsValid() {
prefs.ExitNodeIP = netip.Addr{}
anyChange = true
}
prefs.ExitNodeID = exitNodeID
prefs.ExitNodeIP = netip.Addr{}
} else if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" {
exitNodeIP, err := netip.ParseAddr(exitNodeIPStr)
if exitNodeIP.IsValid() && err == nil {
if prefs.AutoExitNode != "" {
prefs.AutoExitNode = "" // mutually exclusive with ExitNodeIP
anyChange = true
}
if exitNodeIP, err := netip.ParseAddr(exitNodeIPStr); err == nil {
if prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP {
anyChange = true
}
@ -1901,7 +1932,7 @@ func (b *LocalBackend) registerSysPolicyWatch() (unregister func(), err error) {
func (b *LocalBackend) applySysPolicy() (_ ipn.PrefsView, anyChange bool) {
unlock := b.lockAndGetUnlock()
prefs := b.pm.CurrentPrefs().AsStruct()
if !applySysPolicy(prefs, b.lastSuggestedExitNode, b.overrideAlwaysOn) {
if !applySysPolicy(prefs, b.overrideAlwaysOn) {
unlock.UnlockEarly()
return prefs.View(), false
}
@ -1957,8 +1988,8 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
// If auto exit nodes are enabled and our exit node went offline,
// we need to schedule picking a new one.
// TODO(nickkhyl): move the auto exit node logic to a feature package.
if shouldAutoExitNode() {
exitNodeID := b.pm.prefs.ExitNodeID()
if prefs := b.pm.CurrentPrefs(); prefs.AutoExitNode().IsSet() {
exitNodeID := prefs.ExitNodeID()
for _, m := range muts {
mo, ok := m.(netmap.NodeMutationOnline)
if !ok || mo.Online {
@ -2001,9 +2032,27 @@ func mutationsAreWorthyOfTellingIPNBus(muts []netmap.NodeMutation) bool {
return false
}
// setExitNodeID updates prefs to reference an exit node by ID, rather
// setExitNodeID updates prefs to either use the suggestedExitNodeID if AutoExitNode is enabled,
// or resolve ExitNodeIP to an ID and use that. It returns whether prefs was mutated.
func setExitNodeID(prefs *ipn.Prefs, suggestedExitNodeID tailcfg.StableNodeID, nm *netmap.NetworkMap) (prefsChanged bool) {
if prefs.AutoExitNode.IsSet() {
newExitNodeID := cmp.Or(suggestedExitNodeID, unresolvedExitNodeID)
if prefs.ExitNodeID != newExitNodeID {
prefs.ExitNodeID = newExitNodeID
prefsChanged = true
}
if prefs.ExitNodeIP.IsValid() {
prefs.ExitNodeIP = netip.Addr{}
prefsChanged = true
}
return prefsChanged
}
return resolveExitNodeIP(prefs, nm)
}
// resolveExitNodeIP 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) {
func resolveExitNodeIP(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
if nm == nil {
// No netmap, can't resolve anything.
return false
@ -2265,8 +2314,8 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
// And also apply syspolicy settings to the current profile.
// This is important in two cases: when opts.UpdatePrefs is not nil,
// and when Always Mode is enabled and we need to set WantRunning to true.
if newp := b.pm.CurrentPrefs().AsStruct(); applySysPolicy(newp, b.lastSuggestedExitNode, b.overrideAlwaysOn) {
setExitNodeID(newp, cn.NetMap())
if newp := b.pm.CurrentPrefs().AsStruct(); applySysPolicy(newp, b.overrideAlwaysOn) {
setExitNodeID(newp, b.lastSuggestedExitNode, cn.NetMap())
b.pm.setPrefsNoPermCheck(newp.View())
}
prefs := b.pm.CurrentPrefs()
@ -4187,12 +4236,23 @@ func (b *LocalBackend) SetUseExitNodeEnabled(v bool) (ipn.PrefsView, error) {
mp := &ipn.MaskedPrefs{}
if v {
mp.ExitNodeIDSet = true
mp.ExitNodeID = tailcfg.StableNodeID(p0.InternalExitNodePrior())
mp.ExitNodeID = p0.InternalExitNodePrior()
if expr, ok := parseAutoExitNodeID(mp.ExitNodeID); ok {
mp.AutoExitNodeSet = true
mp.AutoExitNode = expr
mp.ExitNodeID = unresolvedExitNodeID
}
} else {
mp.ExitNodeIDSet = true
mp.ExitNodeID = ""
mp.AutoExitNodeSet = true
mp.AutoExitNode = ""
mp.InternalExitNodePriorSet = true
mp.InternalExitNodePrior = p0.ExitNodeID()
if p0.AutoExitNode().IsSet() {
mp.InternalExitNodePrior = tailcfg.StableNodeID(autoExitNodePrefix + p0.AutoExitNode())
} else {
mp.InternalExitNodePrior = p0.ExitNodeID()
}
}
return b.editPrefsLockedOnEntry(mp, unlock)
}
@ -4229,6 +4289,13 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip
mp.InternalExitNodePriorSet = true
}
// Disable automatic exit node selection if the user explicitly sets
// ExitNodeID or ExitNodeIP.
if mp.ExitNodeIDSet || mp.ExitNodeIPSet {
mp.AutoExitNodeSet = true
mp.AutoExitNode = ""
}
// Acquire the lock before checking the profile access to prevent
// TOCTOU issues caused by the current profile changing between the
// check and the actual edit.
@ -4428,9 +4495,14 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
// applySysPolicy returns whether it updated newp,
// but everything in this function treats b.prefs as completely new
// anyway, so its return value can be ignored here.
applySysPolicy(newp, b.lastSuggestedExitNode, b.overrideAlwaysOn)
applySysPolicy(newp, b.overrideAlwaysOn)
if newp.AutoExitNode.IsSet() {
if _, err := b.suggestExitNodeLocked(nil); err != nil {
b.logf("failed to select auto exit node: %v", err)
}
}
// setExitNodeID does likewise. No-op if no exit node resolution is needed.
setExitNodeID(newp, netMap)
setExitNodeID(newp, b.lastSuggestedExitNode, netMap)
// We do this to avoid holding the lock while doing everything else.
@ -7630,10 +7702,53 @@ func longLatDistance(fromLat, fromLong, toLat, toLong float64) float64 {
return earthRadiusMeters * c
}
// shouldAutoExitNode checks for the auto exit node MDM policy.
func shouldAutoExitNode() bool {
exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, "")
return exitNodeIDStr == "auto:any"
const (
// autoExitNodePrefix is the prefix used in [syspolicy.ExitNodeID] values
// to indicate that the string following the prefix is an [ipn.ExitNodeExpression].
autoExitNodePrefix = "auto:"
// unresolvedExitNodeID is a special [tailcfg.StableNodeID] value
// used as an exit node ID to install a blackhole route, preventing
// accidental non-exit-node usage until the [ipn.ExitNodeExpression]
// is evaluated and an actual exit node is selected.
//
// We use "auto:any" for compatibility with older, pre-[ipn.ExitNodeExpression]
// clients that have been using "auto:any" for this purpose for a long time.
unresolvedExitNodeID tailcfg.StableNodeID = "auto:any"
)
// isAutoExitNodeID reports whether the given [tailcfg.StableNodeID] is
// actually an "auto:"-prefixed [ipn.ExitNodeExpression].
func isAutoExitNodeID(id tailcfg.StableNodeID) bool {
_, ok := parseAutoExitNodeID(id)
return ok
}
// parseAutoExitNodeID attempts to parse the given [tailcfg.StableNodeID]
// as an [ExitNodeExpression].
//
// It returns the parsed expression and true on success,
// or an empty string and false if the input does not appear to be
// an [ExitNodeExpression] (i.e., it doesn't start with "auto:").
//
// It is mainly used to parse the [syspolicy.ExitNodeID] value
// when it is set to "auto:<expression>" (e.g., auto:any).
func parseAutoExitNodeID(id tailcfg.StableNodeID) (_ ipn.ExitNodeExpression, ok bool) {
if expr, ok := strings.CutPrefix(string(id), autoExitNodePrefix); ok && expr != "" {
return ipn.ExitNodeExpression(expr), true
}
return "", false
}
func isAllowedAutoExitNodeID(exitNodeID tailcfg.StableNodeID) bool {
if exitNodeID == "" {
return false // an exit node is required
}
if nodes, _ := syspolicy.GetStringArray(syspolicy.AllowedSuggestedExitNodes, nil); nodes != nil {
return slices.Contains(nodes, string(exitNodeID))
}
return true // no policy configured; allow all exit nodes
}
// startAutoUpdate triggers an auto-update attempt. The actual update happens

View File

@ -24,6 +24,7 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
memro "go4.org/mem"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
@ -590,6 +591,391 @@ func TestSetUseExitNodeEnabled(t *testing.T) {
}
}
func makeExitNode(id tailcfg.NodeID, opts ...peerOptFunc) tailcfg.NodeView {
return makePeer(id, append([]peerOptFunc{withCap(26), withSuggest(), withExitRoutes()}, opts...)...)
}
func TestConfigureExitNode(t *testing.T) {
controlURL := "https://localhost:1/"
exitNode1 := makeExitNode(1, withName("node-1"), withDERP(1), withAddresses(netip.MustParsePrefix("100.64.1.1/32")))
exitNode2 := makeExitNode(2, withName("node-2"), withDERP(2), withAddresses(netip.MustParsePrefix("100.64.1.2/32")))
selfNode := makeExitNode(3, withName("node-3"), withDERP(1), withAddresses(netip.MustParsePrefix("100.64.1.3/32")))
clientNetmap := buildNetmapWithPeers(selfNode, exitNode1, exitNode2)
report := &netcheck.Report{
RegionLatency: map[int]time.Duration{
1: 5 * time.Millisecond,
2: 10 * time.Millisecond,
},
PreferredDERP: 1,
}
tests := []struct {
name string
prefs ipn.Prefs
netMap *netmap.NetworkMap
report *netcheck.Report
changePrefs *ipn.MaskedPrefs
useExitNodeEnabled *bool
exitNodeIDPolicy *tailcfg.StableNodeID
exitNodeIPPolicy *netip.Addr
wantPrefs ipn.Prefs
}{
{
name: "exit-node-id-via-prefs", // set exit node ID via prefs
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{ExitNodeID: exitNode1.StableID()},
ExitNodeIDSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
},
{
name: "exit-node-ip-via-prefs", // set exit node IP via prefs (should be resolved to an ID)
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{ExitNodeIP: exitNode1.Addresses().At(0).Addr()},
ExitNodeIPSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
},
{
name: "auto-exit-node-via-prefs/any", // set auto exit node via prefs
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{AutoExitNode: "any"},
AutoExitNodeSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
AutoExitNode: "any",
},
},
{
name: "auto-exit-node-via-prefs/set-exit-node-id-via-prefs", // setting exit node ID explicitly should disable auto exit node
prefs: ipn.Prefs{
ControlURL: controlURL,
AutoExitNode: "any",
ExitNodeID: exitNode1.StableID(),
},
netMap: clientNetmap,
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{ExitNodeID: exitNode2.StableID()},
ExitNodeIDSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode2.StableID(),
AutoExitNode: "", // should be unset
},
},
{
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
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{AutoExitNode: "any"},
AutoExitNodeSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: unresolvedExitNodeID, // cannot resolve; traffic will be dropped
AutoExitNode: "any",
},
},
{
name: "auto-exit-node-via-prefs/any/no-netmap", // similarly, but without a netmap (no exit node should be selected)
prefs: ipn.Prefs{
ControlURL: controlURL,
},
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{AutoExitNode: "any"},
AutoExitNodeSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: unresolvedExitNodeID, // cannot resolve; traffic will be dropped
AutoExitNode: "any",
},
},
{
name: "auto-exit-node-via-prefs/foo", // set auto exit node via prefs with an unknown/unsupported expression
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{AutoExitNode: "foo"},
AutoExitNodeSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(), // unknown exit node expressions should work as "any"
AutoExitNode: "foo",
},
},
{
name: "auto-exit-node-via-prefs/off", // toggle the exit node off after it was set to "any"
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{AutoExitNode: "any"},
AutoExitNodeSet: true,
},
useExitNodeEnabled: ptr.To(false),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: "",
AutoExitNode: "",
InternalExitNodePrior: "auto:any",
},
},
{
name: "auto-exit-node-via-prefs/on", // toggle the exit node on
prefs: ipn.Prefs{
ControlURL: controlURL,
InternalExitNodePrior: "auto:any",
},
netMap: clientNetmap,
report: report,
useExitNodeEnabled: ptr.To(true),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
AutoExitNode: "any",
InternalExitNodePrior: "auto:any",
},
},
{
name: "id-via-policy", // set exit node ID via syspolicy
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
exitNodeIDPolicy: ptr.To(exitNode1.StableID()),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
},
{
name: "id-via-policy/cannot-override-via-prefs/by-id", // syspolicy should take precedence over prefs
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
exitNodeIDPolicy: ptr.To(exitNode1.StableID()),
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeID: exitNode2.StableID(), // this should be ignored
},
ExitNodeIDSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
},
{
name: "id-via-policy/cannot-override-via-prefs/by-ip", // syspolicy should take precedence over prefs
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
exitNodeIDPolicy: ptr.To(exitNode1.StableID()),
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeIP: exitNode2.Addresses().At(0).Addr(), // this should be ignored
},
ExitNodeIPSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
},
{
name: "id-via-policy/cannot-override-via-prefs/by-auto-expr", // syspolicy should take precedence over prefs
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
exitNodeIDPolicy: ptr.To(exitNode1.StableID()),
changePrefs: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AutoExitNode: "any", // this should be ignored
},
AutoExitNodeSet: true,
},
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
},
{
name: "ip-via-policy", // set exit node IP via syspolicy (should be resolved to an ID)
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
exitNodeIPPolicy: ptr.To(exitNode2.Addresses().At(0).Addr()),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode2.StableID(),
},
},
{
name: "auto-any-via-policy", // set auto exit node via syspolicy (an exit node should be selected)
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
AutoExitNode: "any",
},
},
{
name: "auto-any-via-policy/no-report", // set auto exit node via syspolicy without a netcheck report (no exit node should be selected)
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: nil,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: unresolvedExitNodeID,
AutoExitNode: "any",
},
},
{
name: "auto-any-via-policy/no-netmap", // similarly, but without a netmap (no exit node should be selected)
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: nil,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: unresolvedExitNodeID,
AutoExitNode: "any",
},
},
{
name: "auto-foo-via-policy", // set auto exit node via syspolicy with an unknown/unsupported expression
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:foo")),
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(), // unknown exit node expressions should work as "any"
AutoExitNode: "foo",
},
},
{
name: "auto-any-via-policy/toggle-off", // cannot toggle off the exit node if it was set via syspolicy
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
useExitNodeEnabled: ptr.To(false), // should be ignored
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(), // still enforced by the policy setting
AutoExitNode: "any",
InternalExitNodePrior: "auto:any",
},
},
}
syspolicy.RegisterWellKnownSettingsForTest(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Configure policy settings, if any.
var settings []source.TestSetting[string]
if tt.exitNodeIDPolicy != nil {
settings = append(settings, source.TestSettingOf(syspolicy.ExitNodeID, string(*tt.exitNodeIDPolicy)))
}
if tt.exitNodeIPPolicy != nil {
settings = append(settings, source.TestSettingOf(syspolicy.ExitNodeIP, tt.exitNodeIPPolicy.String()))
}
if settings != nil {
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, source.NewTestStoreOf(t, settings...))
} else {
// No syspolicy settings, so don't register a store.
// This allows the test to run in parallel with other tests.
t.Parallel()
}
// Create a new LocalBackend with the given prefs.
// Any syspolicy settings will be applied to the initial prefs.
lb := newTestLocalBackend(t)
lb.SetPrefsForTest(tt.prefs.Clone())
// Then set the netcheck report and netmap, if any.
if tt.report != nil {
lb.MagicConn().SetLastNetcheckReportForTest(t.Context(), tt.report)
}
if tt.netMap != nil {
lb.SetControlClientStatus(lb.cc, controlclient.Status{NetMap: tt.netMap})
}
// If we have a changePrefs, apply it.
if tt.changePrefs != nil {
lb.EditPrefs(tt.changePrefs)
}
// If we need to flip exit node toggle on or off, do it.
if tt.useExitNodeEnabled != nil {
lb.SetUseExitNodeEnabled(*tt.useExitNodeEnabled)
}
// Now check the prefs.
opts := []cmp.Option{
cmpopts.EquateComparable(netip.Addr{}, netip.Prefix{}),
}
if diff := cmp.Diff(&tt.wantPrefs, lb.Prefs().AsStruct(), opts...); diff != "" {
t.Errorf("Prefs(+got -want): %v", diff)
}
})
}
}
func TestInternalAndExternalInterfaces(t *testing.T) {
type interfacePrefix struct {
i netmon.Interface
@ -1646,6 +2032,7 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
prefs *ipn.Prefs
exitNodeIPWant string
exitNodeIDWant string
autoExitNodeWant ipn.ExitNodeExpression
prefsChanged bool
nm *netmap.NetworkMap
lastSuggestedExitNode tailcfg.StableNodeID
@ -1850,19 +2237,38 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
},
},
{
name: "ExitNodeID key is set to auto and last suggested exit node is populated",
name: "ExitNodeID key is set to auto:any and last suggested exit node is populated",
exitNodeIDKey: true,
exitNodeID: "auto:any",
lastSuggestedExitNode: "123",
exitNodeIDWant: "123",
autoExitNodeWant: "any",
prefsChanged: true,
},
{
name: "ExitNodeID key is set to auto and last suggested exit node is not populated",
exitNodeIDKey: true,
exitNodeID: "auto:any",
prefsChanged: true,
exitNodeIDWant: "auto:any",
name: "ExitNodeID key is set to auto:any and last suggested exit node is not populated",
exitNodeIDKey: true,
exitNodeID: "auto:any",
exitNodeIDWant: "auto:any",
autoExitNodeWant: "any",
prefsChanged: true,
},
{
name: "ExitNodeID key is set to auto:foo and last suggested exit node is populated",
exitNodeIDKey: true,
exitNodeID: "auto:foo",
lastSuggestedExitNode: "123",
exitNodeIDWant: "123",
autoExitNodeWant: "foo",
prefsChanged: true,
},
{
name: "ExitNodeID key is set to auto:foo and last suggested exit node is not populated",
exitNodeIDKey: true,
exitNodeID: "auto:foo",
exitNodeIDWant: "auto:any", // should be "auto:any" for compatibility with existing clients
autoExitNodeWant: "foo",
prefsChanged: true,
},
}
@ -1893,7 +2299,7 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
b.pm = pm
b.lastSuggestedExitNode = test.lastSuggestedExitNode
prefs := b.pm.prefs.AsStruct()
if changed := applySysPolicy(prefs, test.lastSuggestedExitNode, false) || setExitNodeID(prefs, test.nm); changed != test.prefsChanged {
if changed := applySysPolicy(prefs, false) || setExitNodeID(prefs, test.lastSuggestedExitNode, test.nm); changed != test.prefsChanged {
t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed)
}
@ -1903,15 +2309,18 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
// preferences to change.
b.SetPrefsForTest(pm.CurrentPrefs().AsStruct())
if got := b.pm.prefs.ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) {
t.Errorf("got %v want %v", got, test.exitNodeIDWant)
if got := b.Prefs().ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) {
t.Errorf("ExitNodeID: got %q; want %q", got, test.exitNodeIDWant)
}
if got := b.pm.prefs.ExitNodeIP(); test.exitNodeIPWant == "" {
if got := b.Prefs().ExitNodeIP(); test.exitNodeIPWant == "" {
if got.String() != "invalid IP" {
t.Errorf("got %v want invalid IP", got)
t.Errorf("ExitNodeIP: got %v want invalid IP", got)
}
} else if got.String() != test.exitNodeIPWant {
t.Errorf("got %v want %v", got, test.exitNodeIPWant)
t.Errorf("ExitNodeIP: got %q; want %q", got, test.exitNodeIPWant)
}
if got := b.Prefs().AutoExitNode(); got != test.autoExitNodeWant {
t.Errorf("AutoExitNode: got %q; want %q", got, test.autoExitNodeWant)
}
})
}
@ -2459,7 +2868,7 @@ func TestApplySysPolicy(t *testing.T) {
t.Run("unit", func(t *testing.T) {
prefs := tt.prefs.Clone()
gotAnyChange := applySysPolicy(prefs, "", false)
gotAnyChange := applySysPolicy(prefs, false)
if gotAnyChange && prefs.Equals(&tt.prefs) {
t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty())
@ -2607,7 +3016,7 @@ func TestPreferencePolicyInfo(t *testing.T) {
prefs := defaultPrefs.AsStruct()
pp.set(prefs, tt.initialValue)
gotAnyChange := applySysPolicy(prefs, "", false)
gotAnyChange := applySysPolicy(prefs, false)
if gotAnyChange != tt.wantChange {
t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantChange)
@ -3288,12 +3697,14 @@ type peerOptFunc func(*tailcfg.Node)
func makePeer(id tailcfg.NodeID, opts ...peerOptFunc) tailcfg.NodeView {
node := &tailcfg.Node{
ID: id,
Key: makeNodeKeyFromID(id),
StableID: tailcfg.StableNodeID(fmt.Sprintf("stable%d", id)),
Name: fmt.Sprintf("peer%d", id),
Online: ptr.To(true),
HomeDERP: int(id),
ID: id,
Key: makeNodeKeyFromID(id),
DiscoKey: makeDiscoKeyFromID(id),
StableID: tailcfg.StableNodeID(fmt.Sprintf("stable%d", id)),
Name: fmt.Sprintf("peer%d", id),
Online: ptr.To(true),
MachineAuthorized: true,
HomeDERP: int(id),
}
for _, opt := range opts {
opt(node)
@ -3363,6 +3774,12 @@ func withNodeKey() peerOptFunc {
}
}
func withAddresses(addresses ...netip.Prefix) peerOptFunc {
return func(n *tailcfg.Node) {
n.Addresses = append(n.Addresses, addresses...)
}
}
func deterministicRegionForTest(t testing.TB, want views.Slice[int], use int) selectRegionFunc {
t.Helper()
@ -4065,9 +4482,9 @@ func TestShouldAutoExitNode(t *testing.T) {
expectedBool: false,
},
{
name: "auto prefix invalid suffix",
name: "auto prefix unknown suffix",
exitNodeIDPolicyValue: "auto:foo",
expectedBool: false,
expectedBool: true, // "auto:{unknown}" is treated as "auto:any"
},
}
@ -4075,12 +4492,7 @@ func TestShouldAutoExitNode(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
syspolicy.ExitNodeID, tt.exitNodeIDPolicyValue,
))
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
got := shouldAutoExitNode()
got := isAutoExitNodeID(tailcfg.StableNodeID(tt.exitNodeIDPolicyValue))
if got != tt.expectedBool {
t.Fatalf("expected %v got %v for %v policy value", tt.expectedBool, got, tt.exitNodeIDPolicyValue)
}
@ -4088,6 +4500,65 @@ func TestShouldAutoExitNode(t *testing.T) {
}
}
func TestParseAutoExitNodeID(t *testing.T) {
tests := []struct {
name string
exitNodeID string
wantOk bool
wantExpr ipn.ExitNodeExpression
}{
{
name: "empty expr",
exitNodeID: "",
wantOk: false,
wantExpr: "",
},
{
name: "no auto prefix",
exitNodeID: "foo",
wantOk: false,
wantExpr: "",
},
{
name: "auto:any",
exitNodeID: "auto:any",
wantOk: true,
wantExpr: ipn.AnyExitNode,
},
{
name: "auto:foo",
exitNodeID: "auto:foo",
wantOk: true,
wantExpr: "foo",
},
{
name: "auto prefix but empty suffix",
exitNodeID: "auto:",
wantOk: false,
wantExpr: "",
},
{
name: "auto prefix no colon",
exitNodeID: "auto",
wantOk: false,
wantExpr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExpr, gotOk := parseAutoExitNodeID(tailcfg.StableNodeID(tt.exitNodeID))
if gotOk != tt.wantOk || gotExpr != tt.wantExpr {
if tt.wantOk {
t.Fatalf("got %v (%q); want %v (%q)", gotOk, gotExpr, tt.wantOk, tt.wantExpr)
} else {
t.Fatalf("got %v (%q); want false", gotOk, gotExpr)
}
}
})
}
}
func TestEnableAutoUpdates(t *testing.T) {
lb := newTestLocalBackend(t)

View File

@ -6,6 +6,7 @@ package ipnlocal
import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
"sync"
@ -1108,10 +1109,17 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
enableLogging := false
connect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: true}, WantRunningSet: true}
disconnect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: false}, WantRunningSet: true}
node1 := testNetmapForNode(1, "node-1", []netip.Prefix{netip.MustParsePrefix("100.64.1.1/32")})
node2 := testNetmapForNode(2, "node-2", []netip.Prefix{netip.MustParsePrefix("100.64.1.2/32")})
node3 := testNetmapForNode(3, "node-3", []netip.Prefix{netip.MustParsePrefix("100.64.1.3/32")})
node3.Peers = []tailcfg.NodeView{node1.SelfNode, node2.SelfNode}
node1 := buildNetmapWithPeers(
makePeer(1, withName("node-1"), withAddresses(netip.MustParsePrefix("100.64.1.1/32"))),
)
node2 := buildNetmapWithPeers(
makePeer(2, withName("node-2"), withAddresses(netip.MustParsePrefix("100.64.1.2/32"))),
)
node3 := buildNetmapWithPeers(
makePeer(3, withName("node-3"), withAddresses(netip.MustParsePrefix("100.64.1.3/32"))),
node1.SelfNode,
node2.SelfNode,
)
routesWithQuad100 := func(extra ...netip.Prefix) []netip.Prefix {
return append(extra, netip.MustParsePrefix("100.100.100.100/32"))
}
@ -1380,33 +1388,75 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
}
}
func testNetmapForNode(userID tailcfg.UserID, name string, addresses []netip.Prefix) *netmap.NetworkMap {
func buildNetmapWithPeers(self tailcfg.NodeView, peers ...tailcfg.NodeView) *netmap.NetworkMap {
const (
domain = "example.com"
magicDNSSuffix = ".test.ts.net"
firstAutoUserID = tailcfg.UserID(10000)
domain = "example.com"
magicDNSSuffix = ".test.ts.net"
)
user := &tailcfg.UserProfile{
ID: userID,
DisplayName: name,
LoginName: strings.Join([]string{name, domain}, "@"),
users := make(map[tailcfg.UserID]tailcfg.UserProfileView)
makeUserForNode := func(n *tailcfg.Node) {
var user *tailcfg.UserProfile
if n.User == 0 {
n.User = firstAutoUserID + tailcfg.UserID(n.ID)
user = &tailcfg.UserProfile{
DisplayName: n.Name,
LoginName: n.Name,
}
} else if _, ok := users[n.User]; !ok {
user = &tailcfg.UserProfile{
DisplayName: fmt.Sprintf("User %d", n.User),
LoginName: fmt.Sprintf("user-%d", n.User),
}
}
if user != nil {
user.ID = n.User
user.LoginName = strings.Join([]string{user.LoginName, domain}, "@")
users[n.User] = user.View()
}
}
self := &tailcfg.Node{
ID: tailcfg.NodeID(1000 + userID),
StableID: tailcfg.StableNodeID("stable-" + name),
User: user.ID,
Name: name + magicDNSSuffix,
Addresses: addresses,
MachineAuthorized: true,
derpmap := &tailcfg.DERPMap{
Regions: make(map[int]*tailcfg.DERPRegion),
}
self.Key = makeNodeKeyFromID(self.ID)
self.DiscoKey = makeDiscoKeyFromID(self.ID)
makeDERPRegionForNode := func(n *tailcfg.Node) {
if n.HomeDERP == 0 {
return // no DERP region
}
if _, ok := derpmap.Regions[n.HomeDERP]; !ok {
r := &tailcfg.DERPRegion{
RegionID: n.HomeDERP,
RegionName: fmt.Sprintf("Region %d", n.HomeDERP),
}
r.Nodes = append(r.Nodes, &tailcfg.DERPNode{
Name: fmt.Sprintf("%da", n.HomeDERP),
RegionID: n.HomeDERP,
})
derpmap.Regions[n.HomeDERP] = r
}
}
updateNode := func(n tailcfg.NodeView) tailcfg.NodeView {
mut := n.AsStruct()
makeUserForNode(mut)
makeDERPRegionForNode(mut)
mut.Name = mut.Name + magicDNSSuffix
return mut.View()
}
self = updateNode(self)
for i := range peers {
peers[i] = updateNode(peers[i])
}
return &netmap.NetworkMap{
SelfNode: self.View(),
Name: self.Name,
Domain: domain,
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{
user.ID: user.View(),
},
SelfNode: self,
Name: self.Name(),
Domain: domain,
Peers: peers,
UserProfiles: users,
DERPMap: derpmap,
}
}

View File

@ -94,6 +94,25 @@ type Prefs struct {
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr
// AutoExitNode is an optional expression that specifies whether and how
// tailscaled should pick an exit node automatically.
//
// If specified, tailscaled will use an exit node based on the expression,
// and will re-evaluate the selection periodically as network conditions,
// available exit nodes, or policy settings change. A blackhole route will
// be installed to prevent traffic from escaping to the local network until
// an exit node is selected. It takes precedence over ExitNodeID and ExitNodeIP.
//
// If empty, tailscaled will not automatically select an exit node.
//
// If the specified expression is invalid or unsupported by the client,
// it falls back to the behavior of [AnyExitNode].
//
// As of 2025-07-02, the only supported value is [AnyExitNode].
// It's a string rather than a boolean to allow future extensibility
// (e.g., AutoExitNode = "mullvad" or AutoExitNode = "geo:us").
AutoExitNode ExitNodeExpression `json:",omitempty"`
// InternalExitNodePrior is the most recently used ExitNodeID in string form. It is set by
// the backend on transition from exit node on to off and used by the
// backend.
@ -325,6 +344,7 @@ type MaskedPrefs struct {
RouteAllSet bool `json:",omitempty"`
ExitNodeIDSet bool `json:",omitempty"`
ExitNodeIPSet bool `json:",omitempty"`
AutoExitNodeSet bool `json:",omitempty"`
InternalExitNodePriorSet bool `json:",omitempty"` // Internal; can't be set by LocalAPI clients
ExitNodeAllowLANAccessSet bool `json:",omitempty"`
CorpDNSSet bool `json:",omitempty"`
@ -533,6 +553,9 @@ func (p *Prefs) pretty(goos string) string {
} else if !p.ExitNodeID.IsZero() {
fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeID, p.ExitNodeAllowLANAccess)
}
if p.AutoExitNode.IsSet() {
fmt.Fprintf(&sb, "auto=%v ", p.AutoExitNode)
}
if len(p.AdvertiseRoutes) > 0 || goos == "linux" {
fmt.Fprintf(&sb, "routes=%v ", p.AdvertiseRoutes)
}
@ -609,6 +632,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.RouteAll == p2.RouteAll &&
p.ExitNodeID == p2.ExitNodeID &&
p.ExitNodeIP == p2.ExitNodeIP &&
p.AutoExitNode == p2.AutoExitNode &&
p.InternalExitNodePrior == p2.InternalExitNodePrior &&
p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
p.CorpDNS == p2.CorpDNS &&
@ -804,6 +828,7 @@ func isRemoteIP(st *ipnstate.Status, ip netip.Addr) bool {
func (p *Prefs) ClearExitNode() {
p.ExitNodeID = ""
p.ExitNodeIP = netip.Addr{}
p.AutoExitNode = ""
}
// ExitNodeLocalIPError is returned when the requested IP address for an exit
@ -1043,3 +1068,23 @@ func (p *LoginProfile) Equals(p2 *LoginProfile) bool {
p.LocalUserID == p2.LocalUserID &&
p.ControlURL == p2.ControlURL
}
// ExitNodeExpression is a string that specifies how an exit node
// should be selected. An empty string means that no exit node
// should be selected.
//
// As of 2025-07-02, the only supported value is [AnyExitNode].
type ExitNodeExpression string
// AnyExitNode indicates that the exit node should be automatically
// selected from the pool of available exit nodes, excluding any
// disallowed by policy (e.g., [syspolicy.AllowedSuggestedExitNodes]).
// The exact implementation is subject to change, but exit nodes
// offering the best performance will be preferred.
const AnyExitNode ExitNodeExpression = "any"
// IsSet reports whether the expression is non-empty and can be used
// to select an exit node.
func (e ExitNodeExpression) IsSet() bool {
return e != ""
}

View File

@ -40,6 +40,7 @@ func TestPrefsEqual(t *testing.T) {
"RouteAll",
"ExitNodeID",
"ExitNodeIP",
"AutoExitNode",
"InternalExitNodePrior",
"ExitNodeAllowLANAccess",
"CorpDNS",
@ -150,6 +151,17 @@ func TestPrefsEqual(t *testing.T) {
true,
},
{
&Prefs{AutoExitNode: ""},
&Prefs{AutoExitNode: "auto:any"},
false,
},
{
&Prefs{AutoExitNode: "auto:any"},
&Prefs{AutoExitNode: "auto:any"},
true,
},
{
&Prefs{},
&Prefs{ExitNodeAllowLANAccess: true},