mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-17 02:58:41 +00:00
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:
parent
0098822981
commit
a8055b5f40
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
45
ipn/prefs.go
45
ipn/prefs.go
@ -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 != ""
|
||||
}
|
||||
|
@ -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},
|
||||
|
Loading…
x
Reference in New Issue
Block a user