ipn/ipn{local,server}: move "staying alive in server mode" from ipnserver to LocalBackend

Currently, we disconnect Tailscale and reset LocalBackend on Windows when the last LocalAPI client
disconnects, unless Unattended Mode is enabled for the current profile. And the implementation
is somewhat racy since the current profile could theoretically change after
(*ipnserver.Server).addActiveHTTPRequest checks (*LocalBackend).InServerMode() and before it calls
(*LocalBackend).SetCurrentUser(nil) (or, previously, (*LocalBackend).ResetForClientDisconnect).

Additionally, we might want to keep Tailscale running and connected while a user is logged in
rather than tying it to whether a LocalAPI client is connected (i.e., while the GUI is running),
even when Unattended Mode is disabled for a profile. This includes scenarios where the new
AlwaysOn mode is enabled, as well as when Tailscale is used on headless Windows editions,
such as Windows Server Core, where the GUI is not supported. It may also be desirable to switch
to the "background" profile when a user logs off from their device or implement other similar
features.

To facilitate these improvements, we move the logic from ipnserver.Server to ipnlocal.LocalBackend,
where it determines whether to keep Tailscale running when the current user disconnects.
We also update the logic that determines whether a connection should be allowed to better reflect
the fact that, currently, LocalAPI connections are not allowed unless:
 - the current UID is "", meaning that either we are not on a multi-user system or Tailscale is idle;
 - the LocalAPI client belongs to the current user (their UIDs are the same);
 - the LocalAPI client is Local System (special case; Local System is always allowed).
Whether Unattended Mode is enabled only affects the error message returned to the Local API client
when the connection is denied.

Updates #14823

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-02-11 12:53:20 -06:00 committed by Nick Khyl
parent bc0cd512ee
commit 9b32ba7f54
3 changed files with 112 additions and 52 deletions

View File

@ -3566,23 +3566,6 @@ func (b *LocalBackend) State() ipn.State {
return b.state
}
// InServerMode reports whether the Tailscale backend is explicitly running in
// "server mode" where it continues to run despite whatever the platform's
// default is. In practice, this is only used on Windows, where the default
// tailscaled behavior is to shut down whenever the GUI disconnects.
//
// On non-Windows platforms, this usually returns false (because people don't
// set unattended mode on other platforms) and also isn't checked on other
// platforms.
//
// TODO(bradfitz): rename to InWindowsUnattendedMode or something? Or make this
// return true on Linux etc and always be called? It's kinda messy now.
func (b *LocalBackend) InServerMode() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.pm.CurrentPrefs().ForceDaemon()
}
// CheckIPNConnectionAllowed returns an error if the specified actor should not
// be allowed to connect or make requests to the LocalAPI currently.
//
@ -3592,16 +3575,10 @@ func (b *LocalBackend) InServerMode() bool {
func (b *LocalBackend) CheckIPNConnectionAllowed(actor ipnauth.Actor) error {
b.mu.Lock()
defer b.mu.Unlock()
serverModeUid := b.pm.CurrentUserID()
if serverModeUid == "" {
// Either this platform isn't a "multi-user" platform or we're not yet
// running as one.
if b.pm.CurrentUserID() == "" {
// There's no "current user" yet; allow the connection.
return nil
}
if !b.pm.CurrentPrefs().ForceDaemon() {
return nil
}
// Always allow Windows SYSTEM user to connect,
// even if Tailscale is currently being used by another user.
if actor.IsLocalSystem() {
@ -3612,10 +3589,21 @@ func (b *LocalBackend) CheckIPNConnectionAllowed(actor ipnauth.Actor) error {
if uid == "" {
return errors.New("empty user uid in connection identity")
}
if uid != serverModeUid {
return fmt.Errorf("Tailscale running in server mode (%q); connection from %q not allowed", b.tryLookupUserName(string(serverModeUid)), b.tryLookupUserName(string(uid)))
if uid == b.pm.CurrentUserID() {
// The connection is from the current user; allow it.
return nil
}
return nil
// The connection is from a different user; block it.
var reason string
if b.pm.CurrentPrefs().ForceDaemon() {
reason = "running in server mode"
} else {
reason = "already in use"
}
return fmt.Errorf("Tailscale %s (%q); connection from %q not allowed",
reason, b.tryLookupUserName(string(b.pm.CurrentUserID())),
b.tryLookupUserName(string(uid)))
}
// tryLookupUserName tries to look up the username for the uid.
@ -3822,10 +3810,53 @@ func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) {
b.currentUser = actor
}
if b.pm.CurrentUserID() != uid {
b.pm.SetCurrentUserID(uid)
b.resetForProfileChangeLockedOnEntry(unlock)
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")
} 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)
}
}
// 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 {
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
}
// 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)
}
// getBackgroundProfileIDLocked returns the 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) {
// 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()
}
// Otherwise, switch to an empty profile and disconnect Tailscale
// until a GUI or CLI client connects.
return "", ""
}
// CurrentUserForTest returns the current user and the associated WindowsUserID.
@ -7062,21 +7093,20 @@ func (b *LocalBackend) ShouldInterceptVIPServiceTCPPort(ap netip.AddrPort) bool
// It will restart the backend on success.
// If the profile is not known, it returns an errProfileNotFound.
func (b *LocalBackend) SwitchProfile(profile ipn.ProfileID) error {
if b.CurrentProfile().ID() == profile {
return nil
}
unlock := b.lockAndGetUnlock()
defer unlock()
if b.pm.CurrentProfile().ID() == profile {
return nil
}
oldControlURL := b.pm.CurrentPrefs().ControlURLOrDefault()
if err := b.pm.SwitchProfile(profile); err != nil {
return err
}
// As an optimization, only reset the dialPlan if the control URL
// changed; we treat an empty URL as "unknown" and always reset.
newControlURL := b.pm.CurrentPrefs().ControlURLOrDefault()
if oldControlURL != newControlURL || oldControlURL == "" || newControlURL == "" {
// As an optimization, only reset the dialPlan if the control URL changed.
if newControlURL := b.pm.CurrentPrefs().ControlURLOrDefault(); oldControlURL != newControlURL {
b.resetDialPlan()
}

View File

@ -77,6 +77,48 @@ func (pm *profileManager) SetCurrentUserID(uid ipn.WindowsUserID) {
}
}
// SetCurrentUserAndProfile sets the current user ID and switches the specified
// profile, if it is accessible to the user. If the profile does not exist,
// or is not accessible, it switches to the user's default profile,
// creating a new one if necessary.
//
// It is a shorthand for [profileManager.SetCurrentUserID] followed by
// [profileManager.SwitchProfile], but it is more efficient as it switches
// directly to the specified profile rather than switching to the user's
// default profile first.
//
// As a special case, if the specified profile ID "", it creates a new
// 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) {
pm.currentUserID = uid
if profileID == "" {
if pm.currentProfile.ID() == "" && pm.currentProfile.LocalUserID() == uid {
return false
}
pm.NewProfileForUser(uid)
return true
}
if profile, err := pm.ProfileByID(profileID); err == nil {
if pm.CurrentProfile().ID() == profileID {
return false
}
if err := pm.SwitchProfile(profile.ID()); err == nil {
return true
}
}
if err := pm.SwitchToDefaultProfile(); err != nil {
pm.logf("%q's default profile cannot be used; creating a new one: %v", uid, err)
pm.NewProfile()
}
return true
}
// DefaultUserProfileID returns [ipn.ProfileID] of the default (last used) profile for the specified user,
// or an empty string if the specified user does not have a default profile.
func (pm *profileManager) DefaultUserProfileID(uid ipn.WindowsUserID) ipn.ProfileID {

View File

@ -42,12 +42,6 @@ type Server struct {
logf logger.Logf
netMon *netmon.Monitor // must be non-nil
backendLogID logid.PublicID
// resetOnZero is whether to call bs.Reset on transition from
// 1->0 active HTTP requests. That is, this is whether the backend is
// being run in "client mode" that requires an active GUI
// connection (such as on Windows by default). Even if this
// is true, the ForceDaemon pref can override this.
resetOnZero bool
// mu guards the fields that follow.
// lock order: mu, then LocalBackend.mu
@ -429,13 +423,8 @@ func (s *Server) addActiveHTTPRequest(req *http.Request, actor ipnauth.Actor) (o
return
}
if s.resetOnZero {
if lb.InServerMode() {
s.logf("client disconnected; staying alive in server mode")
} else {
s.logf("client disconnected; stopping server")
lb.SetCurrentUser(nil)
}
if envknob.GOOS() == "windows" && !actor.IsLocalSystem() {
lb.SetCurrentUser(nil)
}
// Wake up callers waiting for the server to be idle:
@ -459,7 +448,6 @@ func New(logf logger.Logf, logID logid.PublicID, netMon *netmon.Monitor) *Server
backendLogID: logID,
logf: logf,
netMon: netMon,
resetOnZero: envknob.GOOS() == "windows",
}
}