diff --git a/ipn/ipnauth/access.go b/ipn/ipnauth/access.go index 4d0aeb850..53934c64b 100644 --- a/ipn/ipnauth/access.go +++ b/ipn/ipnauth/access.go @@ -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) +) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 5766365b1..fc4bd6e4e 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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) } diff --git a/ipn/ipnserver/actor.go b/ipn/ipnserver/actor.go index 8f743a3eb..7ff96699a 100644 --- a/ipn/ipnserver/actor.go +++ b/ipn/ipnserver/actor.go @@ -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]. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 154d309a1..c75f732b6 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -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) diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go index 35a36130e..d970a4a3c 100644 --- a/util/syspolicy/policy_keys.go +++ b/util/syspolicy/policy_keys.go @@ -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),