diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index b1c273a4f..5ef838039 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -7,11 +7,29 @@ package apitype import ( "tailscale.com/tailcfg" "tailscale.com/types/dnstype" + "tailscale.com/util/ctxkey" ) // LocalAPIHost is the Host header value used by the LocalAPI. const LocalAPIHost = "local-tailscaled.sock" +// RequestReasonHeader is the header used to pass justification for a LocalAPI request, +// such as when a user wants to perform an action they don't have permission for, +// and a policy allows it with justification. As of 2025-01-29, it is only used to +// allow a user to disconnect Tailscale when the "always-on" mode is enabled. +// +// The header value is base64-encoded using the standard encoding defined in RFC 4648. +// +// See tailscale/corp#26146. +const RequestReasonHeader = "X-Tailscale-Reason" + +// RequestReasonKey is the context key used to pass the request reason +// when making a LocalAPI request via [tailscale.LocalClient]. +// It's value is a raw string. An empty string means no reason was provided. +// +// See tailscale/corp#26146. +var RequestReasonKey = ctxkey.New(RequestReasonHeader, "") + // WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler. // In successful whois responses, Node and UserProfile are never nil. type WhoIsResponse struct { diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index f440b19a8..eecd05dfd 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -10,6 +10,7 @@ import ( "cmp" "context" "crypto/tls" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -238,7 +239,12 @@ func SetVersionMismatchHandler(f func(clientVer, serverVer string)) { } func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) { - slurp, _, err := lc.sendWithHeaders(ctx, method, path, wantStatus, body, nil) + var headers http.Header + if reason := apitype.RequestReasonKey.Value(ctx); reason != "" { + reasonBase64 := base64.StdEncoding.EncodeToString([]byte(reason)) + headers = http.Header{apitype.RequestReasonHeader: {reasonBase64}} + } + slurp, _, err := lc.sendWithHeaders(ctx, method, path, wantStatus, body, headers) return slurp, err } @@ -824,6 +830,11 @@ func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) { return &p, nil } +// EditPrefs updates the [ipn.Prefs] of the current Tailscale profile, applying the changes in mp. +// It returns an error if the changes cannot be applied, such as due to the caller's access rights +// or a policy restriction. An optional reason or justification for the request can be +// provided as a context value using [apitype.RequestReasonKey]. If permitted by policy, +// access may be granted, and the reason will be logged for auditing purposes. func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, jsonBody(mp)) if err != nil { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index b13dfd0e4..fb7cc98a3 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -386,6 +386,14 @@ type LocalBackend struct { // backend is healthy and captive portal detection is not required // (sending false). needsCaptiveDetection chan bool + + // overrideAlwaysOn is whether [syspolicy.AlwaysOn] is overridden by the user + // and should have no impact on the WantRunning state until the policy changes, + // or the user re-connects manually, switches to a different profile, etc. + // Notably, this is true when [syspolicy.AlwaysOnOverrideWithReason] is enabled, + // and the user has disconnected with a reason. + // See tailscale/corp#26146. + overrideAlwaysOn bool } // HealthTracker returns the health tracker for the backend. @@ -1564,7 +1572,7 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control b.logf("SetControlClientStatus failed to select auto exit node: %v", err) } } - if applySysPolicy(prefs, b.lastSuggestedExitNode) { + if applySysPolicy(prefs, b.lastSuggestedExitNode, b.overrideAlwaysOn) { prefsChanged = true } if setExitNodeID(prefs, curNetMap) { @@ -1733,7 +1741,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) (anyChange bool) { +func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID, overrideAlwaysOn bool) (anyChange bool) { if controlURL, err := syspolicy.GetString(syspolicy.ControlURL, prefs.ControlURL); err == nil && prefs.ControlURL != controlURL { prefs.ControlURL = controlURL anyChange = true @@ -1795,7 +1803,7 @@ func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID } } - if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); alwaysOn && !prefs.WantRunning { + if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); alwaysOn && !overrideAlwaysOn && !prefs.WantRunning { prefs.WantRunning = true anyChange = true } @@ -1834,7 +1842,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) { + if !applySysPolicy(prefs, b.lastSuggestedExitNode, b.overrideAlwaysOn) { unlock.UnlockEarly() return prefs.View(), false } @@ -1844,6 +1852,15 @@ func (b *LocalBackend) applySysPolicy() (_ ipn.PrefsView, anyChange bool) { // sysPolicyChanged is a callback triggered by syspolicy when it detects // a change in one or more syspolicy settings. func (b *LocalBackend) sysPolicyChanged(policy *rsop.PolicyChange) { + if policy.HasChanged(syspolicy.AlwaysOn) || policy.HasChanged(syspolicy.AlwaysOnOverrideWithReason) { + // If the AlwaysOn or the AlwaysOnOverrideWithReason policy has changed, + // we should reset the overrideAlwaysOn flag, as the override might + // no longer be valid. + b.mu.Lock() + b.overrideAlwaysOn = false + b.mu.Unlock() + } + if policy.HasChanged(syspolicy.AllowedSuggestedExitNodes) { b.refreshAllowedSuggestions() // Re-evaluate exit node suggestion now that the policy setting has changed. @@ -4018,6 +4035,12 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip return ipn.PrefsView{}, err } + // If a user has enough rights to disconnect, such as when [syspolicy.AlwaysOn] + // is disabled, or [syspolicy.AlwaysOnOverrideWithReason] is also set and the user + // provides a reason for disconnecting, then we should not force the "always on" + // mode on them until the policy changes, they switch to a different profile, etc. + b.overrideAlwaysOn = true + // TODO(nickkhyl): check the ReconnectAfter policy here. If configured, // start a timer to automatically reconnect after the specified duration. } @@ -4025,6 +4048,10 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip return b.editPrefsLockedOnEntry(mp, unlock) } +func (b *LocalBackend) resetAlwaysOnOverrideLocked() { + b.overrideAlwaysOn = false +} + // Warning: b.mu must be held on entry, but it unlocks it on the way out. // TODO(bradfitz): redo the locking on all these weird methods like this. func (b *LocalBackend) editPrefsLockedOnEntry(mp *ipn.MaskedPrefs, unlock unlockOnce) (ipn.PrefsView, error) { @@ -4113,7 +4140,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce) // applySysPolicyToPrefsLocked 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) + applySysPolicy(newp, b.lastSuggestedExitNode, b.overrideAlwaysOn) // setExitNodeID does likewise. No-op if no exit node resolution is needed. setExitNodeID(newp, netMap) // We do this to avoid holding the lock while doing everything else. @@ -4161,6 +4188,11 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce) } if err := b.pm.SetPrefs(prefs, np); err != nil { b.logf("failed to save new controlclient state: %v", err) + } else if prefs.WantRunning() { + // Reset the always-on override if WantRunning is true in the new prefs, + // such as when the user toggles the Connected switch in the GUI + // or runs `tailscale up`. + b.resetAlwaysOnOverrideLocked() } if newp.AutoUpdate.Apply.EqualBool(true) { @@ -5587,6 +5619,7 @@ func (b *LocalBackend) ResetForClientDisconnect() { b.resetAuthURLLocked() b.activeLogin = "" b.resetDialPlan() + b.resetAlwaysOnOverrideLocked() b.setAtomicValuesFromPrefsLocked(ipn.PrefsView{}) b.enterStateLockedOnEntry(ipn.Stopped, unlock) } @@ -7125,6 +7158,7 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err b.lastServeConfJSON = mem.B(nil) b.serveConfig = ipn.ServeConfigView{} b.lastSuggestedExitNode = "" + b.resetAlwaysOnOverrideLocked() b.enterStateLockedOnEntry(ipn.NoState, unlock) // Reset state; releases b.mu b.health.SetLocalLogConfigHealth(nil) return b.Start(ipn.Options{}) diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 3455cab1f..dfc2e45bd 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -1861,7 +1861,7 @@ func TestSetExitNodeIDPolicy(t *testing.T) { b.lastSuggestedExitNode = test.lastSuggestedExitNode prefs := b.pm.prefs.AsStruct() - if changed := applySysPolicy(prefs, test.lastSuggestedExitNode) || setExitNodeID(prefs, test.nm); changed != test.prefsChanged { + if changed := applySysPolicy(prefs, test.lastSuggestedExitNode, false) || setExitNodeID(prefs, test.nm); changed != test.prefsChanged { t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed) } @@ -2421,7 +2421,7 @@ func TestApplySysPolicy(t *testing.T) { t.Run("unit", func(t *testing.T) { prefs := tt.prefs.Clone() - gotAnyChange := applySysPolicy(prefs, "") + gotAnyChange := applySysPolicy(prefs, "", false) if gotAnyChange && prefs.Equals(&tt.prefs) { t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty()) @@ -2569,7 +2569,7 @@ func TestPreferencePolicyInfo(t *testing.T) { prefs := defaultPrefs.AsStruct() pp.set(prefs, tt.initialValue) - gotAnyChange := applySysPolicy(prefs, "") + gotAnyChange := applySysPolicy(prefs, "", false) if gotAnyChange != tt.wantChange { t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantChange) diff --git a/ipn/ipnserver/actor.go b/ipn/ipnserver/actor.go index 7ff96699a..652716670 100644 --- a/ipn/ipnserver/actor.go +++ b/ipn/ipnserver/actor.go @@ -32,8 +32,12 @@ type actor struct { logf logger.Logf ci *ipnauth.ConnIdentity - clientID ipnauth.ClientID - isLocalSystem bool // whether the actor is the Windows' Local System identity. + clientID ipnauth.ClientID + // accessOverrideReason specifies the reason for overriding certain access restrictions, + // such as permitting a user to disconnect when the always-on mode is enabled, + // provided that such justification is allowed by the policy. + accessOverrideReason string + isLocalSystem bool // whether the actor is the Windows' Local System identity. } func newActor(logf logger.Logf, c net.Conn) (*actor, error) { @@ -59,19 +63,43 @@ func newActor(logf logger.Logf, c net.Conn) (*actor, error) { return &actor{logf: logf, ci: ci, clientID: clientID, isLocalSystem: connIsLocalSystem(ci)}, nil } +// actorWithAccessOverride returns a new actor that carries the specified +// reason for overriding certain access restrictions, if permitted by the +// policy. If the reason is "", it returns the base actor. +func actorWithAccessOverride(baseActor *actor, reason string) *actor { + if reason == "" { + return baseActor + } + return &actor{ + logf: baseActor.logf, + ci: baseActor.ci, + clientID: baseActor.clientID, + accessOverrideReason: reason, + isLocalSystem: baseActor.isLocalSystem, + } +} + // CheckProfileAccess implements [ipnauth.Actor]. func (a *actor) CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess ipnauth.ProfileAccess) error { + // TODO(nickkhyl): return errors of more specific types and have them + // translated to the appropriate HTTP status codes in the API handler. if profile.LocalUserID() != a.UserID() { return errors.New("the target profile does not belong to the user") } switch requestedAccess { case ipnauth.Disconnect: if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); alwaysOn { - // TODO(nickkhyl): check if disconnecting with justifications is allowed - // and whether a justification is included in the request. - return errors.New("profile access denied: always-on mode is enabled") + if allowWithReason, _ := syspolicy.GetBoolean(syspolicy.AlwaysOnOverrideWithReason, false); !allowWithReason { + return errors.New("disconnect not allowed: always-on mode is enabled") + } + if a.accessOverrideReason == "" { + return errors.New("disconnect not allowed: reason required") + } + maybeUsername, _ := a.Username() // best-effort + a.logf("Tailscale (%q) is being disconnected by %q: %v", profile.Name(), maybeUsername, a.accessOverrideReason) + // TODO(nickkhyl): Log the reason to the audit log once we have one. } - return nil + return nil // disconnect is allowed default: return errors.New("the requested operation is not allowed") } diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 3d9c9e3d4..a08643667 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -7,6 +7,7 @@ package ipnserver import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -20,6 +21,7 @@ import ( "sync/atomic" "unicode" + "tailscale.com/client/tailscale/apitype" "tailscale.com/envknob" "tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnlocal" @@ -198,10 +200,18 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { if actor, ok := ci.(*actor); ok { lah.PermitRead, lah.PermitWrite = actor.Permissions(lb.OperatorUserID()) lah.PermitCert = actor.CanFetchCerts() + reason, err := base64.StdEncoding.DecodeString(r.Header.Get(apitype.RequestReasonHeader)) + if err != nil { + http.Error(w, "invalid reason header", http.StatusBadRequest) + return + } + lah.Actor = actorWithAccessOverride(actor, string(reason)) } else if testenv.InTest() { lah.PermitRead, lah.PermitWrite = true, true } - lah.Actor = ci + if lah.Actor == nil { + lah.Actor = ci + } lah.ServeHTTP(w, r) return } diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go index ec5e83b18..a955ce094 100644 --- a/util/syspolicy/policy_keys.go +++ b/util/syspolicy/policy_keys.go @@ -34,7 +34,13 @@ const ( // Warning: This policy setting is experimental and may change or be removed in the future. // It may also not be fully supported by all Tailscale clients until it is out of experimental status. // See tailscale/corp#26247, tailscale/corp#26248 and tailscale/corp#26249 for more information. - AlwaysOn Key = "AlwaysOn" + AlwaysOn Key = "AlwaysOn.Enabled" + + // AlwaysOnOverrideWithReason is a boolean key that alters the behavior + // of [AlwaysOn]. When true, the user is allowed to disconnect Tailscale + // by providing a reason. The reason is logged and sent to the control + // for auditing purposes. It has no effect when [AlwaysOn] is false. + AlwaysOnOverrideWithReason Key = "AlwaysOn.OverrideWithReason" // ExitNodeID is the exit node's node id. default ""; if blank, no exit node is forced. // Exit node ID takes precedence over exit node IP. @@ -150,6 +156,7 @@ var implicitDefinitions = []*setting.Definition{ // Device policy settings (can only be configured on a per-device basis): setting.NewDefinition(AllowedSuggestedExitNodes, setting.DeviceSetting, setting.StringListValue), setting.NewDefinition(AlwaysOn, setting.DeviceSetting, setting.BooleanValue), + setting.NewDefinition(AlwaysOnOverrideWithReason, setting.DeviceSetting, setting.BooleanValue), setting.NewDefinition(ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue), setting.NewDefinition(AuthKey, setting.DeviceSetting, setting.StringValue), setting.NewDefinition(CheckUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),