ipn/ipn{local,server}: extract logic that determines the "best" Tailscale profile to use

In this PR, we further refactor LocalBackend and Unattended Mode to extract the logic that determines
which profile should be used at the time of the check, such as when a LocalAPI client connects or disconnects.
We then update (*LocalBackend).switchProfileLockedOnEntry to to switch to the profile returned by
(*LocalBackend).resolveBestProfileLocked() rather than to the caller-specified specified profile, and rename it
to switchToBestProfileLockedOnEntry.

This is done in preparation for updating (*LocalBackend).getBackgroundProfileIDLocked to support Always-On
mode by determining which profile to use based on which users, if any, are currently logged in and have an active
foreground desktop session.

Updates #14823
Updates tailscale/corp#26247

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-02-12 17:43:53 -06:00 committed by Nick Khyl
parent b7f508fccf
commit 7aef4fd44d
3 changed files with 118 additions and 43 deletions

View File

@ -3795,14 +3795,15 @@ func (b *LocalBackend) shouldUploadServices() bool {
//
// On non-multi-user systems, the actor should be set to nil.
func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) {
var uid ipn.WindowsUserID
if actor != nil {
uid = actor.UserID()
}
unlock := b.lockAndGetUnlock()
defer unlock()
var userIdentifier string
if user := cmp.Or(actor, b.currentUser); user != nil {
maybeUsername, _ := user.Username()
userIdentifier = cmp.Or(maybeUsername, string(user.UserID()))
}
if actor != b.currentUser {
if c, ok := b.currentUser.(ipnauth.ActorCloser); ok {
c.Close()
@ -3810,46 +3811,108 @@ func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) {
b.currentUser = actor
}
if b.pm.CurrentUserID() == uid {
return
}
var profileID ipn.ProfileID
if actor != nil {
profileID = b.pm.DefaultUserProfileID(uid)
} else if uid, profileID = b.getBackgroundProfileIDLocked(); profileID != "" {
b.logf("client disconnected; staying alive in server mode")
var action string
if actor == nil {
action = "disconnected"
} else {
b.logf("client disconnected; stopping server")
}
if err := b.switchProfileLockedOnEntry(uid, profileID, unlock); err != nil {
b.logf("failed switching profile to %q: %v", profileID, err)
action = "connected"
}
reason := fmt.Sprintf("client %s (%s)", action, userIdentifier)
b.switchToBestProfileLockedOnEntry(reason, unlock)
}
// switchProfileLockedOnEntry is like [LocalBackend.SwitchProfile],
// but b.mu must held on entry, but it is released on exit.
func (b *LocalBackend) switchProfileLockedOnEntry(uid ipn.WindowsUserID, profileID ipn.ProfileID, unlock unlockOnce) error {
// switchToBestProfileLockedOnEntry selects the best profile to use,
// as reported by [LocalBackend.resolveBestProfileLocked], and switches
// to it, unless it's already the current profile. The reason indicates
// why the profile is being switched, such as due to a client connecting
// or disconnecting and is used for logging.
//
// b.mu must held on entry. It is released on exit.
func (b *LocalBackend) switchToBestProfileLockedOnEntry(reason string, unlock unlockOnce) {
defer unlock()
if b.pm.CurrentUserID() == uid && b.pm.CurrentProfile().ID() == profileID {
return nil
}
oldControlURL := b.pm.CurrentPrefs().ControlURLOrDefault()
if changed := b.pm.SetCurrentUserAndProfile(uid, profileID); !changed {
return nil
uid, profileID, background := b.resolveBestProfileLocked()
cp, switched := b.pm.SetCurrentUserAndProfile(uid, profileID)
switch {
case !switched && cp.ID() == "":
b.logf("%s: staying on empty profile", reason)
case !switched:
b.logf("%s: staying on profile %q (%s)", reason, cp.UserProfile().LoginName, cp.ID())
case cp.ID() == "":
b.logf("%s: disconnecting Tailscale", reason)
case background:
b.logf("%s: switching to background profile %q (%s)", reason, cp.UserProfile().LoginName, cp.ID())
default:
b.logf("%s: switching to profile %q (%s)", reason, cp.UserProfile().LoginName, cp.ID())
}
if !switched {
return
}
// As an optimization, only reset the dialPlan if the control URL changed.
if newControlURL := b.pm.CurrentPrefs().ControlURLOrDefault(); oldControlURL != newControlURL {
b.resetDialPlan()
}
return b.resetForProfileChangeLockedOnEntry(unlock)
if err := b.resetForProfileChangeLockedOnEntry(unlock); err != nil {
// TODO(nickkhyl): The actual reset cannot fail. However,
// the TKA initialization or [LocalBackend.Start] can fail.
// These errors are not critical as far as we're concerned.
// But maybe we should post a notification to the API watchers?
b.logf("failed switching profile to %q: %v", profileID, err)
}
}
// getBackgroundProfileIDLocked returns the profile ID to use when no GUI/CLI
// client is connected, or "" if Tailscale should not run in the background.
// resolveBestProfileLocked returns the best profile to use based on the current
// state of the backend, such as whether a GUI/CLI client is connected and whether
// the unattended mode is enabled.
//
// It returns the user ID, profile ID, and whether the returned profile is
// considered a background profile. A background profile is used when no OS user
// is actively using Tailscale, such as when no GUI/CLI client is connected
// and Unattended Mode is enabled (see also [LocalBackend.getBackgroundProfileLocked]).
// An empty profile ID indicates that Tailscale should switch to an empty profile.
//
// b.mu must be held.
func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, profileID ipn.ProfileID, isBackground bool) {
// If a GUI/CLI client is connected, use the connected user's profile, which means
// either the current profile if owned by the user, or their default profile.
if b.currentUser != nil {
cp := b.pm.CurrentProfile()
uid := b.currentUser.UserID()
var profileID ipn.ProfileID
// TODO(nickkhyl): check if the current profile is allowed on the device,
// such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet.
// See tailscale/corp#26249.
if cp.LocalUserID() == uid {
profileID = cp.ID()
} else {
profileID = b.pm.DefaultUserProfileID(uid)
}
return uid, profileID, false
}
// Otherwise, if on Windows, use the background profile if one is set.
// This includes staying on the current profile if Unattended Mode is enabled.
// If the returned background profileID is "", Tailscale will disconnect
// and remain idle until a GUI or CLI client connects.
if goos := envknob.GOOS(); goos == "windows" {
uid, profileID := b.getBackgroundProfileLocked()
return uid, profileID, true
}
// On other platforms, however, Tailscale continues to run in the background
// using the current profile.
//
// TODO(nickkhyl): check if the current profile is allowed on the device,
// such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet.
// See tailscale/corp#26249.
return b.pm.CurrentUserID(), b.pm.CurrentProfile().ID(), false
}
// getBackgroundProfileLocked returns the user and profile ID to use when no GUI/CLI
// client is connected, or "","" if Tailscale should not run in the background.
// As of 2025-02-07, it is only used on Windows.
func (b *LocalBackend) getBackgroundProfileIDLocked() (ipn.WindowsUserID, ipn.ProfileID) {
func (b *LocalBackend) getBackgroundProfileLocked() (ipn.WindowsUserID, ipn.ProfileID) {
// If Unattended Mode is enabled for the current profile, keep using it.
if b.pm.CurrentPrefs().ForceDaemon() {
return b.pm.CurrentProfile().LocalUserID(), b.pm.CurrentProfile().ID()
@ -7190,9 +7253,9 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
// Needs to happen without b.mu held.
defer prevCC.Shutdown()
}
if err := b.initTKALocked(); err != nil {
return err
}
// TKA errors should not prevent resetting the backend state.
// However, we should still return the error to the caller.
tkaErr := b.initTKALocked()
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
b.lastSuggestedExitNode = ""
@ -7201,6 +7264,9 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
b.setAtomicValuesFromPrefsLocked(b.pm.CurrentPrefs())
b.enterStateLockedOnEntry(ipn.NoState, unlock) // Reset state; releases b.mu
b.health.SetLocalLogConfigHealth(nil)
if tkaErr != nil {
return tkaErr
}
return b.Start(ipn.Options{})
}

View File

@ -91,24 +91,25 @@ func (pm *profileManager) SetCurrentUserID(uid ipn.WindowsUserID) {
// profile for the user and switches to it, unless the current profile
// is already a new, empty profile owned by the user.
//
// It reports whether the call resulted in a profile switch.
func (pm *profileManager) SetCurrentUserAndProfile(uid ipn.WindowsUserID, profileID ipn.ProfileID) (changed bool) {
// It returns the current profile and whether the call resulted
// in a profile switch.
func (pm *profileManager) SetCurrentUserAndProfile(uid ipn.WindowsUserID, profileID ipn.ProfileID) (cp ipn.LoginProfileView, changed bool) {
pm.currentUserID = uid
if profileID == "" {
if pm.currentProfile.ID() == "" && pm.currentProfile.LocalUserID() == uid {
return false
return pm.currentProfile, false
}
pm.NewProfileForUser(uid)
return true
return pm.currentProfile, true
}
if profile, err := pm.ProfileByID(profileID); err == nil {
if pm.CurrentProfile().ID() == profileID {
return false
return pm.currentProfile, false
}
if err := pm.SwitchProfile(profile.ID()); err == nil {
return true
return pm.currentProfile, true
}
}
@ -116,7 +117,7 @@ func (pm *profileManager) SetCurrentUserAndProfile(uid ipn.WindowsUserID, profil
pm.logf("%q's default profile cannot be used; creating a new one: %v", uid, err)
pm.NewProfile()
}
return true
return pm.currentProfile, true
}
// DefaultUserProfileID returns [ipn.ProfileID] of the default (last used) profile for the specified user,

View File

@ -32,6 +32,7 @@ type actor struct {
ci *ipnauth.ConnIdentity
clientID ipnauth.ClientID
userID ipn.WindowsUserID // cached Windows user ID of the connected client process.
// 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.
@ -59,7 +60,14 @@ func newActor(logf logger.Logf, c net.Conn) (*actor, error) {
// connectivity on domain-joined devices and/or be slow.
clientID = ipnauth.ClientIDFrom(pid)
}
return &actor{logf: logf, ci: ci, clientID: clientID, isLocalSystem: connIsLocalSystem(ci)}, nil
return &actor{
logf: logf,
ci: ci,
clientID: clientID,
userID: ci.WindowsUserID(),
isLocalSystem: connIsLocalSystem(ci),
},
nil
}
// actorWithAccessOverride returns a new actor that carries the specified
@ -106,7 +114,7 @@ func (a *actor) IsLocalAdmin(operatorUID string) bool {
// UserID implements [ipnauth.Actor].
func (a *actor) UserID() ipn.WindowsUserID {
return a.ci.WindowsUserID()
return a.userID
}
func (a *actor) pid() int {