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. // Used internally by LocalBackend as part of exit node usage toggling.
// No CLI flag for this. // No CLI flag for this.
continue 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) 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 RouteAll bool
ExitNodeID tailcfg.StableNodeID ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr ExitNodeIP netip.Addr
AutoExitNode ExitNodeExpression
InternalExitNodePrior tailcfg.StableNodeID InternalExitNodePrior tailcfg.StableNodeID
ExitNodeAllowLANAccess bool ExitNodeAllowLANAccess bool
CorpDNS 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) RouteAll() bool { return v.ж.RouteAll }
func (v PrefsView) ExitNodeID() tailcfg.StableNodeID { return v.ж.ExitNodeID } func (v PrefsView) ExitNodeID() tailcfg.StableNodeID { return v.ж.ExitNodeID }
func (v PrefsView) ExitNodeIP() netip.Addr { return v.ж.ExitNodeIP } 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) InternalExitNodePrior() tailcfg.StableNodeID { return v.ж.InternalExitNodePrior }
func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess } func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess }
func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS } func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS }
@ -179,6 +180,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
RouteAll bool RouteAll bool
ExitNodeID tailcfg.StableNodeID ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr ExitNodeIP netip.Addr
AutoExitNode ExitNodeExpression
InternalExitNodePrior tailcfg.StableNodeID InternalExitNodePrior tailcfg.StableNodeID
ExitNodeAllowLANAccess bool ExitNodeAllowLANAccess bool
CorpDNS bool CorpDNS bool

View File

@ -912,13 +912,14 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
hadPAC := b.prevIfState.HasPAC() hadPAC := b.prevIfState.HasPAC()
b.prevIfState = ifst b.prevIfState = ifst
b.pauseOrResumeControlClientLocked() b.pauseOrResumeControlClientLocked()
if delta.Major && shouldAutoExitNode() { prefs := b.pm.CurrentPrefs()
if delta.Major && prefs.AutoExitNode().IsSet() {
b.refreshAutoExitNode = true b.refreshAutoExitNode = true
} }
var needReconfig bool var needReconfig bool
// If the network changed and we're using an exit node and allowing LAN access, we may need to reconfigure. // 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) b.logf("linkChange: in state %v; updating LAN routes", b.state)
needReconfig = true needReconfig = true
} }
@ -941,8 +942,8 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
// If the local network configuration has changed, our filter may // If the local network configuration has changed, our filter may
// need updating to tweak default routes. // need updating to tweak default routes.
b.updateFilterLocked(b.pm.CurrentPrefs()) b.updateFilterLocked(prefs)
updateExitNodeUsageWarning(b.pm.CurrentPrefs(), delta.New, b.health) updateExitNodeUsageWarning(prefs, delta.New, b.health)
cn := b.currentNode() cn := b.currentNode()
nm := cn.NetMap() nm := cn.NetMap()
@ -1623,17 +1624,17 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
prefsChanged = true 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. // Re-evaluate exit node suggestion in case circumstances have changed.
_, err := b.suggestExitNodeLocked(curNetMap) _, err := b.suggestExitNodeLocked(curNetMap)
if err != nil && !errors.Is(err, ErrNoPreferredDERP) { if err != nil && !errors.Is(err, ErrNoPreferredDERP) {
b.logf("SetControlClientStatus failed to select auto exit node: %v", err) b.logf("SetControlClientStatus failed to select auto exit node: %v", err)
} }
} }
if applySysPolicy(prefs, b.lastSuggestedExitNode, b.overrideAlwaysOn) { if setExitNodeID(prefs, b.lastSuggestedExitNode, curNetMap) {
prefsChanged = true
}
if setExitNodeID(prefs, curNetMap) {
prefsChanged = true prefsChanged = true
} }
@ -1800,7 +1801,7 @@ var preferencePolicies = []preferencePolicyInfo{
// applySysPolicy overwrites configured preferences with policies that may be // applySysPolicy overwrites configured preferences with policies that may be
// configured by the system administrator in an OS-specific way. // 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 { if controlURL, err := syspolicy.GetString(syspolicy.ControlURL, prefs.ControlURL); err == nil && prefs.ControlURL != controlURL {
prefs.ControlURL = controlURL prefs.ControlURL = controlURL
anyChange = true anyChange = true
@ -1839,21 +1840,51 @@ func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" { if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr) exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
if shouldAutoExitNode() && lastSuggestedExitNode != "" {
exitNodeID = lastSuggestedExitNode // Try to parse the policy setting value as an "auto:"-prefixed [ipn.ExitNodeExpression],
} // and update prefs if it differs from the current one.
// Note: when exitNodeIDStr == "auto" && lastSuggestedExitNode == "", // This includes cases where it was previously an expression but no longer is,
// then exitNodeID is now "auto" which will never match a peer's node ID. // or where it wasn't before but now is.
// When there is no a peer matching the node ID, traffic will blackhole, autoExitNode, useAutoExitNode := parseAutoExitNodeID(exitNodeID)
// preventing accidental non-exit-node usage when a policy is in effect that requires an exit node. if prefs.AutoExitNode != autoExitNode {
if prefs.ExitNodeID != exitNodeID || prefs.ExitNodeIP.IsValid() { 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 anyChange = true
} }
prefs.ExitNodeID = exitNodeID
prefs.ExitNodeIP = netip.Addr{}
} else if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" { } else if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" {
exitNodeIP, err := netip.ParseAddr(exitNodeIPStr) if prefs.AutoExitNode != "" {
if exitNodeIP.IsValid() && err == nil { prefs.AutoExitNode = "" // mutually exclusive with ExitNodeIP
anyChange = true
}
if exitNodeIP, err := netip.ParseAddr(exitNodeIPStr); err == nil {
if prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP { if prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP {
anyChange = true anyChange = true
} }
@ -1901,7 +1932,7 @@ func (b *LocalBackend) registerSysPolicyWatch() (unregister func(), err error) {
func (b *LocalBackend) applySysPolicy() (_ ipn.PrefsView, anyChange bool) { func (b *LocalBackend) applySysPolicy() (_ ipn.PrefsView, anyChange bool) {
unlock := b.lockAndGetUnlock() unlock := b.lockAndGetUnlock()
prefs := b.pm.CurrentPrefs().AsStruct() prefs := b.pm.CurrentPrefs().AsStruct()
if !applySysPolicy(prefs, b.lastSuggestedExitNode, b.overrideAlwaysOn) { if !applySysPolicy(prefs, b.overrideAlwaysOn) {
unlock.UnlockEarly() unlock.UnlockEarly()
return prefs.View(), false 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, // If auto exit nodes are enabled and our exit node went offline,
// we need to schedule picking a new one. // we need to schedule picking a new one.
// TODO(nickkhyl): move the auto exit node logic to a feature package. // TODO(nickkhyl): move the auto exit node logic to a feature package.
if shouldAutoExitNode() { if prefs := b.pm.CurrentPrefs(); prefs.AutoExitNode().IsSet() {
exitNodeID := b.pm.prefs.ExitNodeID() exitNodeID := prefs.ExitNodeID()
for _, m := range muts { for _, m := range muts {
mo, ok := m.(netmap.NodeMutationOnline) mo, ok := m.(netmap.NodeMutationOnline)
if !ok || mo.Online { if !ok || mo.Online {
@ -2001,9 +2032,27 @@ func mutationsAreWorthyOfTellingIPNBus(muts []netmap.NodeMutation) bool {
return false 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. // 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 { if nm == nil {
// No netmap, can't resolve anything. // No netmap, can't resolve anything.
return false return false
@ -2265,8 +2314,8 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
// And also apply syspolicy settings to the current profile. // And also apply syspolicy settings to the current profile.
// This is important in two cases: when opts.UpdatePrefs is not nil, // 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. // 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) { if newp := b.pm.CurrentPrefs().AsStruct(); applySysPolicy(newp, b.overrideAlwaysOn) {
setExitNodeID(newp, cn.NetMap()) setExitNodeID(newp, b.lastSuggestedExitNode, cn.NetMap())
b.pm.setPrefsNoPermCheck(newp.View()) b.pm.setPrefsNoPermCheck(newp.View())
} }
prefs := b.pm.CurrentPrefs() prefs := b.pm.CurrentPrefs()
@ -4187,12 +4236,23 @@ func (b *LocalBackend) SetUseExitNodeEnabled(v bool) (ipn.PrefsView, error) {
mp := &ipn.MaskedPrefs{} mp := &ipn.MaskedPrefs{}
if v { if v {
mp.ExitNodeIDSet = true 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 { } else {
mp.ExitNodeIDSet = true mp.ExitNodeIDSet = true
mp.ExitNodeID = "" mp.ExitNodeID = ""
mp.AutoExitNodeSet = true
mp.AutoExitNode = ""
mp.InternalExitNodePriorSet = true 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) return b.editPrefsLockedOnEntry(mp, unlock)
} }
@ -4229,6 +4289,13 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip
mp.InternalExitNodePriorSet = true 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 // Acquire the lock before checking the profile access to prevent
// TOCTOU issues caused by the current profile changing between the // TOCTOU issues caused by the current profile changing between the
// check and the actual edit. // check and the actual edit.
@ -4428,9 +4495,14 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
// applySysPolicy returns whether it updated newp, // applySysPolicy returns whether it updated newp,
// but everything in this function treats b.prefs as completely new // but everything in this function treats b.prefs as completely new
// anyway, so its return value can be ignored here. // 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 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. // 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 return earthRadiusMeters * c
} }
// shouldAutoExitNode checks for the auto exit node MDM policy. const (
func shouldAutoExitNode() bool { // autoExitNodePrefix is the prefix used in [syspolicy.ExitNodeID] values
exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, "") // to indicate that the string following the prefix is an [ipn.ExitNodeExpression].
return exitNodeIDStr == "auto:any" 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 // startAutoUpdate triggers an auto-update attempt. The actual update happens

View File

@ -24,6 +24,7 @@ import (
"time" "time"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
memro "go4.org/mem" memro "go4.org/mem"
"go4.org/netipx" "go4.org/netipx"
"golang.org/x/net/dns/dnsmessage" "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) { func TestInternalAndExternalInterfaces(t *testing.T) {
type interfacePrefix struct { type interfacePrefix struct {
i netmon.Interface i netmon.Interface
@ -1646,6 +2032,7 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
prefs *ipn.Prefs prefs *ipn.Prefs
exitNodeIPWant string exitNodeIPWant string
exitNodeIDWant string exitNodeIDWant string
autoExitNodeWant ipn.ExitNodeExpression
prefsChanged bool prefsChanged bool
nm *netmap.NetworkMap nm *netmap.NetworkMap
lastSuggestedExitNode tailcfg.StableNodeID 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, exitNodeIDKey: true,
exitNodeID: "auto:any", exitNodeID: "auto:any",
lastSuggestedExitNode: "123", lastSuggestedExitNode: "123",
exitNodeIDWant: "123", exitNodeIDWant: "123",
autoExitNodeWant: "any",
prefsChanged: true, prefsChanged: true,
}, },
{ {
name: "ExitNodeID key is set to auto and last suggested exit node is not populated", name: "ExitNodeID key is set to auto:any and last suggested exit node is not populated",
exitNodeIDKey: true, exitNodeIDKey: true,
exitNodeID: "auto:any", exitNodeID: "auto:any",
prefsChanged: true, exitNodeIDWant: "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.pm = pm
b.lastSuggestedExitNode = test.lastSuggestedExitNode b.lastSuggestedExitNode = test.lastSuggestedExitNode
prefs := b.pm.prefs.AsStruct() 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) 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. // preferences to change.
b.SetPrefsForTest(pm.CurrentPrefs().AsStruct()) b.SetPrefsForTest(pm.CurrentPrefs().AsStruct())
if got := b.pm.prefs.ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) { if got := b.Prefs().ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) {
t.Errorf("got %v want %v", got, 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" { 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 { } 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) { t.Run("unit", func(t *testing.T) {
prefs := tt.prefs.Clone() prefs := tt.prefs.Clone()
gotAnyChange := applySysPolicy(prefs, "", false) gotAnyChange := applySysPolicy(prefs, false)
if gotAnyChange && prefs.Equals(&tt.prefs) { if gotAnyChange && prefs.Equals(&tt.prefs) {
t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty()) t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty())
@ -2607,7 +3016,7 @@ func TestPreferencePolicyInfo(t *testing.T) {
prefs := defaultPrefs.AsStruct() prefs := defaultPrefs.AsStruct()
pp.set(prefs, tt.initialValue) pp.set(prefs, tt.initialValue)
gotAnyChange := applySysPolicy(prefs, "", false) gotAnyChange := applySysPolicy(prefs, false)
if gotAnyChange != tt.wantChange { if gotAnyChange != tt.wantChange {
t.Errorf("anyChange=%v, want %v", 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 { func makePeer(id tailcfg.NodeID, opts ...peerOptFunc) tailcfg.NodeView {
node := &tailcfg.Node{ node := &tailcfg.Node{
ID: id, ID: id,
Key: makeNodeKeyFromID(id), Key: makeNodeKeyFromID(id),
StableID: tailcfg.StableNodeID(fmt.Sprintf("stable%d", id)), DiscoKey: makeDiscoKeyFromID(id),
Name: fmt.Sprintf("peer%d", id), StableID: tailcfg.StableNodeID(fmt.Sprintf("stable%d", id)),
Online: ptr.To(true), Name: fmt.Sprintf("peer%d", id),
HomeDERP: int(id), Online: ptr.To(true),
MachineAuthorized: true,
HomeDERP: int(id),
} }
for _, opt := range opts { for _, opt := range opts {
opt(node) 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 { func deterministicRegionForTest(t testing.TB, want views.Slice[int], use int) selectRegionFunc {
t.Helper() t.Helper()
@ -4065,9 +4482,9 @@ func TestShouldAutoExitNode(t *testing.T) {
expectedBool: false, expectedBool: false,
}, },
{ {
name: "auto prefix invalid suffix", name: "auto prefix unknown suffix",
exitNodeIDPolicyValue: "auto:foo", 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
policyStore := source.NewTestStoreOf(t, source.TestSettingOf( got := isAutoExitNodeID(tailcfg.StableNodeID(tt.exitNodeIDPolicyValue))
syspolicy.ExitNodeID, tt.exitNodeIDPolicyValue,
))
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
got := shouldAutoExitNode()
if got != tt.expectedBool { if got != tt.expectedBool {
t.Fatalf("expected %v got %v for %v policy value", tt.expectedBool, got, tt.exitNodeIDPolicyValue) 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) { func TestEnableAutoUpdates(t *testing.T) {
lb := newTestLocalBackend(t) lb := newTestLocalBackend(t)

View File

@ -6,6 +6,7 @@ package ipnlocal
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/netip" "net/netip"
"strings" "strings"
"sync" "sync"
@ -1108,10 +1109,17 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
enableLogging := false enableLogging := false
connect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: true}, WantRunningSet: true} connect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: true}, WantRunningSet: true}
disconnect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: false}, 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")}) node1 := buildNetmapWithPeers(
node2 := testNetmapForNode(2, "node-2", []netip.Prefix{netip.MustParsePrefix("100.64.1.2/32")}) makePeer(1, withName("node-1"), withAddresses(netip.MustParsePrefix("100.64.1.1/32"))),
node3 := testNetmapForNode(3, "node-3", []netip.Prefix{netip.MustParsePrefix("100.64.1.3/32")}) )
node3.Peers = []tailcfg.NodeView{node1.SelfNode, node2.SelfNode} 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 { routesWithQuad100 := func(extra ...netip.Prefix) []netip.Prefix {
return append(extra, netip.MustParsePrefix("100.100.100.100/32")) 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 ( const (
domain = "example.com" firstAutoUserID = tailcfg.UserID(10000)
magicDNSSuffix = ".test.ts.net" domain = "example.com"
magicDNSSuffix = ".test.ts.net"
) )
user := &tailcfg.UserProfile{
ID: userID, users := make(map[tailcfg.UserID]tailcfg.UserProfileView)
DisplayName: name, makeUserForNode := func(n *tailcfg.Node) {
LoginName: strings.Join([]string{name, domain}, "@"), 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), derpmap := &tailcfg.DERPMap{
StableID: tailcfg.StableNodeID("stable-" + name), Regions: make(map[int]*tailcfg.DERPRegion),
User: user.ID,
Name: name + magicDNSSuffix,
Addresses: addresses,
MachineAuthorized: true,
} }
self.Key = makeNodeKeyFromID(self.ID) makeDERPRegionForNode := func(n *tailcfg.Node) {
self.DiscoKey = makeDiscoKeyFromID(self.ID) 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{ return &netmap.NetworkMap{
SelfNode: self.View(), SelfNode: self,
Name: self.Name, Name: self.Name(),
Domain: domain, Domain: domain,
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{ Peers: peers,
user.ID: user.View(), UserProfiles: users,
}, DERPMap: derpmap,
} }
} }

View File

@ -94,6 +94,25 @@ type Prefs struct {
ExitNodeID tailcfg.StableNodeID ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr 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 // 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 // the backend on transition from exit node on to off and used by the
// backend. // backend.
@ -325,6 +344,7 @@ type MaskedPrefs struct {
RouteAllSet bool `json:",omitempty"` RouteAllSet bool `json:",omitempty"`
ExitNodeIDSet bool `json:",omitempty"` ExitNodeIDSet bool `json:",omitempty"`
ExitNodeIPSet bool `json:",omitempty"` ExitNodeIPSet bool `json:",omitempty"`
AutoExitNodeSet bool `json:",omitempty"`
InternalExitNodePriorSet bool `json:",omitempty"` // Internal; can't be set by LocalAPI clients InternalExitNodePriorSet bool `json:",omitempty"` // Internal; can't be set by LocalAPI clients
ExitNodeAllowLANAccessSet bool `json:",omitempty"` ExitNodeAllowLANAccessSet bool `json:",omitempty"`
CorpDNSSet bool `json:",omitempty"` CorpDNSSet bool `json:",omitempty"`
@ -533,6 +553,9 @@ func (p *Prefs) pretty(goos string) string {
} else if !p.ExitNodeID.IsZero() { } else if !p.ExitNodeID.IsZero() {
fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeID, p.ExitNodeAllowLANAccess) 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" { if len(p.AdvertiseRoutes) > 0 || goos == "linux" {
fmt.Fprintf(&sb, "routes=%v ", p.AdvertiseRoutes) fmt.Fprintf(&sb, "routes=%v ", p.AdvertiseRoutes)
} }
@ -609,6 +632,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.RouteAll == p2.RouteAll && p.RouteAll == p2.RouteAll &&
p.ExitNodeID == p2.ExitNodeID && p.ExitNodeID == p2.ExitNodeID &&
p.ExitNodeIP == p2.ExitNodeIP && p.ExitNodeIP == p2.ExitNodeIP &&
p.AutoExitNode == p2.AutoExitNode &&
p.InternalExitNodePrior == p2.InternalExitNodePrior && p.InternalExitNodePrior == p2.InternalExitNodePrior &&
p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess && p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
p.CorpDNS == p2.CorpDNS && p.CorpDNS == p2.CorpDNS &&
@ -804,6 +828,7 @@ func isRemoteIP(st *ipnstate.Status, ip netip.Addr) bool {
func (p *Prefs) ClearExitNode() { func (p *Prefs) ClearExitNode() {
p.ExitNodeID = "" p.ExitNodeID = ""
p.ExitNodeIP = netip.Addr{} p.ExitNodeIP = netip.Addr{}
p.AutoExitNode = ""
} }
// ExitNodeLocalIPError is returned when the requested IP address for an exit // 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.LocalUserID == p2.LocalUserID &&
p.ControlURL == p2.ControlURL 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", "RouteAll",
"ExitNodeID", "ExitNodeID",
"ExitNodeIP", "ExitNodeIP",
"AutoExitNode",
"InternalExitNodePrior", "InternalExitNodePrior",
"ExitNodeAllowLANAccess", "ExitNodeAllowLANAccess",
"CorpDNS", "CorpDNS",
@ -150,6 +151,17 @@ func TestPrefsEqual(t *testing.T) {
true, true,
}, },
{
&Prefs{AutoExitNode: ""},
&Prefs{AutoExitNode: "auto:any"},
false,
},
{
&Prefs{AutoExitNode: "auto:any"},
&Prefs{AutoExitNode: "auto:any"},
true,
},
{ {
&Prefs{}, &Prefs{},
&Prefs{ExitNodeAllowLANAccess: true}, &Prefs{ExitNodeAllowLANAccess: true},