mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-11 13:18:53 +00:00
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:
@@ -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{})
|
||||
|
@@ -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)
|
||||
|
@@ -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")
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user