ipn/ipn{auth,server,local}: initial support for the always-on mode

In this PR, we update LocalBackend to set WantRunning=true when applying policy settings
to the current profile's prefs, if the "always-on" mode is enabled.

We also implement a new (*LocalBackend).EditPrefsAs() method, which is like EditPrefs
but accepts an actor (e.g., a LocalAPI client's identity) that initiated the change.
If WantRunning is being set to false, the new EditPrefsAs method checks whether the actor
has ipnauth.Disconnect access to the profile and propagates an error if they do not.

Finally, we update (*ipnserver.actor).CheckProfileAccess to allow a disconnect
only if the "always-on" mode is not enabled by the AlwaysOn policy setting.

This is not a comprehensive solution to the "always-on" mode across platforms,
as instead of disconnecting a user could achieve the same effect by creating
a new empty profile, initiating a reauth, or by deleting the profile.
These are the things we should address in future PRs.

Updates #14823

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-01-30 18:29:02 -06:00 committed by Nick Khyl
parent 535a3dbebd
commit 02ad21717f
5 changed files with 54 additions and 2 deletions

View File

@ -6,3 +6,9 @@ package ipnauth
// ProfileAccess is a bitmask representing the requested, required, or granted
// access rights to an [ipn.LoginProfile].
type ProfileAccess uint32
// Define access rights that might be granted or denied on a per-profile basis.
const (
// Disconnect is required to disconnect (or switch from) a Tailscale profile.
Disconnect = ProfileAccess(1 << iota)
)

View File

@ -1795,6 +1795,11 @@ func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID
}
}
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); alwaysOn && !prefs.WantRunning {
prefs.WantRunning = true
anyChange = true
}
for _, opt := range preferencePolicies {
if po, err := syspolicy.GetPreferenceOption(opt.key); err == nil {
curVal := opt.get(prefs.View())
@ -3984,7 +3989,15 @@ func (b *LocalBackend) MaybeClearAppConnector(mp *ipn.MaskedPrefs) error {
return err
}
// EditPrefs applies the changes in mp to the current prefs,
// acting as the tailscaled itself rather than a specific user.
func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) {
return b.EditPrefsAs(mp, ipnauth.Self)
}
// EditPrefsAs is like EditPrefs, but makes the change as the specified actor.
// It returns an error if the actor is not allowed to make the change.
func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ipn.PrefsView, error) {
if mp.SetsInternal() {
return ipn.PrefsView{}, errors.New("can't set Internal fields")
}
@ -3995,8 +4008,20 @@ func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) {
mp.InternalExitNodePriorSet = true
}
// 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.
unlock := b.lockAndGetUnlock()
defer unlock()
if mp.WantRunningSet && !mp.WantRunning && b.pm.CurrentPrefs().WantRunning() {
if err := actor.CheckProfileAccess(b.pm.CurrentProfile(), ipnauth.Disconnect); err != nil {
return ipn.PrefsView{}, err
}
// TODO(nickkhyl): check the ReconnectAfter policy here. If configured,
// start a timer to automatically reconnect after the specified duration.
}
return b.editPrefsLockedOnEntry(mp, unlock)
}

View File

@ -17,6 +17,7 @@ import (
"tailscale.com/types/logger"
"tailscale.com/util/ctxkey"
"tailscale.com/util/osuser"
"tailscale.com/util/syspolicy"
"tailscale.com/version"
)
@ -63,7 +64,17 @@ func (a *actor) CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess
if profile.LocalUserID() != a.UserID() {
return errors.New("the target profile does not belong to the user")
}
return errors.New("the requested operation is not allowed")
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")
}
return nil
default:
return errors.New("the requested operation is not allowed")
}
}
// IsLocalSystem implements [ipnauth.Actor].

View File

@ -1381,7 +1381,7 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) {
return
}
var err error
prefs, err = h.b.EditPrefs(mp)
prefs, err = h.b.EditPrefsAs(mp, h.Actor)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)

View File

@ -26,6 +26,15 @@ const (
ControlURL Key = "LoginURL" // default ""; if blank, ipn uses ipn.DefaultControlURL.
LogTarget Key = "LogTarget" // default ""; if blank logging uses logtail.DefaultHost.
Tailnet Key = "Tailnet" // default ""; if blank, no tailnet name is sent to the server.
// AlwaysOn is a boolean key that controls whether Tailscale
// should always remain in a connected state, and the user should
// not be able to disconnect at their discretion.
//
// 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.
AlwaysOn Key = "AlwaysOn"
// 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.
// To find the node ID, go to /api.md#device.
@ -139,6 +148,7 @@ const (
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(ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(AuthKey, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(CheckUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),