summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ipn/ipnauth/access.go6
-rw-r--r--ipn/ipnlocal/local.go25
-rw-r--r--ipn/ipnserver/actor.go13
-rw-r--r--ipn/localapi/localapi.go2
-rw-r--r--util/syspolicy/policy_keys.go10
5 files changed, 54 insertions, 2 deletions
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),