client/tailscale,ipn/ipn{local,server},util/syspolicy: implement the AlwaysOn.OverrideWithReason policy setting

In this PR, we update client/tailscale.LocalClient to allow sending requests with an optional X-Tailscale-Reason
header. We then update ipn/ipnserver.{actor,Server} to retrieve this reason, if specified, and use it to determine
whether ipnauth.Disconnect is allowed when the AlwaysOn.OverrideWithReason policy setting is enabled.
For now, we log the reason, along with the profile and OS username, to the backend log.

Finally, we update LocalBackend to remember when a disconnect was permitted and do not reconnect automatically
unless the policy changes.

Updates tailscale/corp#26146

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-01-31 16:14:13 -06:00 committed by Nick Khyl
parent 2c02f712d1
commit d832467461
7 changed files with 125 additions and 17 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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{})

View File

@ -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)

View File

@ -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")
}

View File

@ -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
}

View File

@ -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),