ipn/{ipnauth,ipnlocal,localapi}: make EditPrefs return an error if changing exit node is restricted by policy

We extract checkEditPrefsAccessLocked, adjustEditPrefsLocked, and onEditPrefsLocked from the EditPrefs
execution path, defining when each step is performed and what behavior is allowed at each stage.

Currently, this is primarily used to support Always On mode, to handle the Exit Node enablement toggle,
and to report prefs edit metrics.

We then use it to enforce Exit Node policy settings by preventing users from setting an exit node
and making EditPrefs return an error when an exit node is restricted by policy. This enforcement is also
extended to the Exit Node toggle.

These changes prepare for supporting Exit Node overrides when permitted by policy and preventing logout
while Always On mode is enabled.

In the future, implementation of these methods can be delegated to ipnext extensions via the feature hooks.

Updates tailscale/corp#29969
Updates tailscale/corp#26249

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl
2025-07-07 17:04:07 -05:00
committed by Nick Khyl
parent a6f6478129
commit f1c7b463cd
5 changed files with 191 additions and 79 deletions
+53 -30
View File
@@ -501,29 +501,30 @@ func TestLazyMachineKeyGeneration(t *testing.T) {
func TestZeroExitNodeViaLocalAPI(t *testing.T) {
lb := newTestLocalBackend(t)
user := &ipnauth.TestActor{}
// Give it an initial exit node in use.
if _, err := lb.EditPrefs(&ipn.MaskedPrefs{
if _, err := lb.EditPrefsAs(&ipn.MaskedPrefs{
ExitNodeIDSet: true,
Prefs: ipn.Prefs{
ExitNodeID: "foo",
},
}); err != nil {
}, user); err != nil {
t.Fatalf("enabling first exit node: %v", err)
}
// SetUseExitNodeEnabled(false) "remembers" the prior exit node.
if _, err := lb.SetUseExitNodeEnabled(false); err != nil {
if _, err := lb.SetUseExitNodeEnabled(user, false); err != nil {
t.Fatal("expected failure")
}
// Zero the exit node
pv, err := lb.EditPrefs(&ipn.MaskedPrefs{
pv, err := lb.EditPrefsAs(&ipn.MaskedPrefs{
ExitNodeIDSet: true,
Prefs: ipn.Prefs{
ExitNodeID: "",
},
})
}, user)
if err != nil {
t.Fatalf("enabling first exit node: %v", err)
@@ -539,29 +540,30 @@ func TestZeroExitNodeViaLocalAPI(t *testing.T) {
func TestSetUseExitNodeEnabled(t *testing.T) {
lb := newTestLocalBackend(t)
user := &ipnauth.TestActor{}
// Can't turn it on if it never had an old value.
if _, err := lb.SetUseExitNodeEnabled(true); err == nil {
if _, err := lb.SetUseExitNodeEnabled(user, true); err == nil {
t.Fatal("expected success")
}
// But we can turn it off when it's already off.
if _, err := lb.SetUseExitNodeEnabled(false); err != nil {
if _, err := lb.SetUseExitNodeEnabled(user, false); err != nil {
t.Fatal("expected failure")
}
// Give it an initial exit node in use.
if _, err := lb.EditPrefs(&ipn.MaskedPrefs{
if _, err := lb.EditPrefsAs(&ipn.MaskedPrefs{
ExitNodeIDSet: true,
Prefs: ipn.Prefs{
ExitNodeID: "foo",
},
}); err != nil {
}, user); err != nil {
t.Fatalf("enabling first exit node: %v", err)
}
// Now turn off that exit node.
if prefs, err := lb.SetUseExitNodeEnabled(false); err != nil {
if prefs, err := lb.SetUseExitNodeEnabled(user, false); err != nil {
t.Fatal("expected failure")
} else {
if g, w := prefs.ExitNodeID(), tailcfg.StableNodeID(""); g != w {
@@ -573,7 +575,7 @@ func TestSetUseExitNodeEnabled(t *testing.T) {
}
// And turn it back on.
if prefs, err := lb.SetUseExitNodeEnabled(true); err != nil {
if prefs, err := lb.SetUseExitNodeEnabled(user, true); err != nil {
t.Fatal("expected failure")
} else {
if g, w := prefs.ExitNodeID(), tailcfg.StableNodeID("foo"); g != w {
@@ -585,9 +587,9 @@ func TestSetUseExitNodeEnabled(t *testing.T) {
}
// Verify we block setting an Internal field.
if _, err := lb.EditPrefs(&ipn.MaskedPrefs{
if _, err := lb.EditPrefsAs(&ipn.MaskedPrefs{
InternalExitNodePriorSet: true,
}); err == nil {
}, user); err == nil {
t.Fatalf("unexpected success; want an error trying to set an internal field")
}
}
@@ -612,16 +614,18 @@ func TestConfigureExitNode(t *testing.T) {
}
tests := []struct {
name string
prefs ipn.Prefs
netMap *netmap.NetworkMap
report *netcheck.Report
changePrefs *ipn.MaskedPrefs
useExitNodeEnabled *bool
exitNodeIDPolicy *tailcfg.StableNodeID
exitNodeIPPolicy *netip.Addr
exitNodeAllowedIDs []tailcfg.StableNodeID // nil if all IDs are allowed for auto exit nodes
wantPrefs ipn.Prefs
name string
prefs ipn.Prefs
netMap *netmap.NetworkMap
report *netcheck.Report
changePrefs *ipn.MaskedPrefs
useExitNodeEnabled *bool
exitNodeIDPolicy *tailcfg.StableNodeID
exitNodeIPPolicy *netip.Addr
exitNodeAllowedIDs []tailcfg.StableNodeID // nil if all IDs are allowed for auto exit nodes
wantChangePrefsErr error // if non-nil, the error we expect from [LocalBackend.EditPrefsAs]
wantPrefs ipn.Prefs
wantExitNodeToggleErr error // if non-nil, the error we expect from [LocalBackend.SetUseExitNodeEnabled]
}{
{
name: "exit-node-id-via-prefs", // set exit node ID via prefs
@@ -804,6 +808,7 @@ func TestConfigureExitNode(t *testing.T) {
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
wantChangePrefsErr: errManagedByPolicy,
},
{
name: "id-via-policy/cannot-override-via-prefs/by-ip", // syspolicy should take precedence over prefs
@@ -822,6 +827,7 @@ func TestConfigureExitNode(t *testing.T) {
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
wantChangePrefsErr: errManagedByPolicy,
},
{
name: "id-via-policy/cannot-override-via-prefs/by-auto-expr", // syspolicy should take precedence over prefs
@@ -840,6 +846,7 @@ func TestConfigureExitNode(t *testing.T) {
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(),
},
wantChangePrefsErr: errManagedByPolicy,
},
{
name: "ip-via-policy", // set exit node IP via syspolicy (should be resolved to an ID)
@@ -999,15 +1006,16 @@ func TestConfigureExitNode(t *testing.T) {
prefs: ipn.Prefs{
ControlURL: controlURL,
},
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
useExitNodeEnabled: ptr.To(false), // should be ignored
netMap: clientNetmap,
report: report,
exitNodeIDPolicy: ptr.To(tailcfg.StableNodeID("auto:any")),
useExitNodeEnabled: ptr.To(false), // should fail with an error
wantExitNodeToggleErr: errManagedByPolicy,
wantPrefs: ipn.Prefs{
ControlURL: controlURL,
ExitNodeID: exitNode1.StableID(), // still enforced by the policy setting
AutoExitNode: "any",
InternalExitNodePrior: "auto:any",
InternalExitNodePrior: "",
},
},
}
@@ -1046,14 +1054,17 @@ func TestConfigureExitNode(t *testing.T) {
lb.SetControlClientStatus(lb.cc, controlclient.Status{NetMap: tt.netMap})
}
user := &ipnauth.TestActor{}
// If we have a changePrefs, apply it.
if tt.changePrefs != nil {
lb.EditPrefs(tt.changePrefs)
_, err := lb.EditPrefsAs(tt.changePrefs, user)
checkError(t, err, tt.wantChangePrefsErr, true)
}
// If we need to flip exit node toggle on or off, do it.
if tt.useExitNodeEnabled != nil {
lb.SetUseExitNodeEnabled(*tt.useExitNodeEnabled)
_, err := lb.SetUseExitNodeEnabled(user, *tt.useExitNodeEnabled)
checkError(t, err, tt.wantExitNodeToggleErr, true)
}
// Now check the prefs.
@@ -6218,6 +6229,18 @@ func TestDisplayMessageIPNBus(t *testing.T) {
}
}
func checkError(tb testing.TB, got, want error, fatal bool) {
tb.Helper()
f := tb.Errorf
if fatal {
f = tb.Fatalf
}
if (want == nil) != (got == nil) ||
(want != nil && got != nil && want.Error() != got.Error() && !errors.Is(got, want)) {
f("gotErr: %v; wantErr: %v", got, want)
}
}
func toStrings[T ~string](in []T) []string {
out := make([]string, len(in))
for i, v := range in {