tailscale/ipn/ipnlocal/desktop_sessions.go
Nick Khyl 6df5c8f32e various: keep tailscale connected when Always On mode is enabled on Windows
In this PR, we enable the registration of LocalBackend extensions to exclude code specific to certain
platforms or environments. We then introduce desktopSessionsExt, which is included only in Windows builds
and only if the ts_omit_desktop_sessions tag is disabled for the build. This extension tracks desktop sessions
and switches to (or remains on) the appropriate profile when a user signs in or out, locks their screen,
or disconnects a remote session.

As desktopSessionsExt requires an ipn/desktop.SessionManager, we register it with tsd.System
for the tailscaled subprocess on Windows.

We also fix a bug in the sessionWatcher implementation where it attempts to close a nil channel on stop.

Updates #14823
Updates tailscale/corp#26247

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-02-14 16:40:54 -06:00

179 lines
5.8 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Both the desktop session manager and multi-user support
// are currently available only on Windows.
// This file does not need to be built for other platforms.
//go:build windows && !ts_omit_desktop_sessions
package ipnlocal
import (
"cmp"
"errors"
"fmt"
"sync"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/desktop"
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/util/syspolicy"
)
func init() {
feature.Register("desktop-sessions")
RegisterExtension("desktop-sessions", newDesktopSessionsExt)
}
// desktopSessionsExt implements [localBackendExtension].
var _ localBackendExtension = (*desktopSessionsExt)(nil)
// desktopSessionsExt extends [LocalBackend] with desktop session management.
// It keeps Tailscale running in the background if Always-On mode is enabled,
// and switches to an appropriate profile when a user signs in or out,
// locks their screen, or disconnects a remote session.
type desktopSessionsExt struct {
logf logger.Logf
sm desktop.SessionManager
*LocalBackend // or nil, until Init is called
cleanup []func() // cleanup functions to call on shutdown
// mu protects all following fields.
// When both mu and [LocalBackend.mu] need to be taken,
// [LocalBackend.mu] must be taken before mu.
mu sync.Mutex
id2sess map[desktop.SessionID]*desktop.Session
}
// newDesktopSessionsExt returns a new [desktopSessionsExt],
// or an error if [desktop.SessionManager] is not available.
func newDesktopSessionsExt(logf logger.Logf, sys *tsd.System) (localBackendExtension, error) {
sm, ok := sys.SessionManager.GetOK()
if !ok {
return nil, errors.New("session manager is not available")
}
return &desktopSessionsExt{logf: logf, sm: sm, id2sess: make(map[desktop.SessionID]*desktop.Session)}, nil
}
// Init implements [localBackendExtension].
func (e *desktopSessionsExt) Init(lb *LocalBackend) (err error) {
e.LocalBackend = lb
unregisterResolver := lb.RegisterBackgroundProfileResolver(e.getBackgroundProfile)
unregisterSessionCb, err := e.sm.RegisterStateCallback(e.updateDesktopSessionState)
if err != nil {
unregisterResolver()
return fmt.Errorf("session callback registration failed: %w", err)
}
e.cleanup = []func(){unregisterResolver, unregisterSessionCb}
return nil
}
// updateDesktopSessionState is a [desktop.SessionStateCallback]
// invoked by [desktop.SessionManager] once for each existing session
// and whenever the session state changes. It updates the session map
// and switches to the best profile if necessary.
func (e *desktopSessionsExt) updateDesktopSessionState(session *desktop.Session) {
e.mu.Lock()
if session.Status != desktop.ClosedSession {
e.id2sess[session.ID] = session
} else {
delete(e.id2sess, session.ID)
}
e.mu.Unlock()
var action string
switch session.Status {
case desktop.ForegroundSession:
// The user has either signed in or unlocked their session.
// For remote sessions, this may also mean the user has connected.
// The distinction isn't important for our purposes,
// so let's always say "signed in".
action = "signed in to"
case desktop.BackgroundSession:
action = "locked"
case desktop.ClosedSession:
action = "signed out from"
default:
panic("unreachable")
}
maybeUsername, _ := session.User.Username()
userIdentifier := cmp.Or(maybeUsername, string(session.User.UserID()), "user")
reason := fmt.Sprintf("%s %s session %v", userIdentifier, action, session.ID)
e.SwitchToBestProfile(reason)
}
// getBackgroundProfile is a [profileResolver] that works as follows:
//
// If Always-On mode is disabled, it returns no profile ("","",false).
//
// If AlwaysOn mode is enabled, it returns the current profile unless:
// - The current user has signed out.
// - Another user has a foreground (i.e. active/unlocked) session.
//
// If the current user's session runs in the background and no other user
// has a foreground session, it returns the current profile. This applies
// when a locally signed-in user locks their screen or when a remote user
// disconnects without signing out.
//
// In all other cases, it returns no profile ("","",false).
//
// It is called with [LocalBackend.mu] locked.
func (e *desktopSessionsExt) getBackgroundProfile() (_ ipn.WindowsUserID, _ ipn.ProfileID, ok bool) {
e.mu.Lock()
defer e.mu.Unlock()
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); !alwaysOn {
return "", "", false
}
isCurrentUserSingedIn := false
var foregroundUIDs []ipn.WindowsUserID
for _, s := range e.id2sess {
switch uid := s.User.UserID(); uid {
case e.pm.CurrentUserID():
isCurrentUserSingedIn = true
if s.Status == desktop.ForegroundSession {
// Keep the current profile if the user has a foreground session.
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
default:
if s.Status == desktop.ForegroundSession {
foregroundUIDs = append(foregroundUIDs, uid)
}
}
}
// If there's no current user (e.g., tailscaled just started), or if the current
// user has no foreground session, switch to the default profile of the first user
// with a foreground session, if any.
for _, uid := range foregroundUIDs {
if profileID := e.pm.DefaultUserProfileID(uid); profileID != "" {
return uid, profileID, true
}
}
// If no user has a foreground session but the current user is still signed in,
// keep the current profile even if the session is not in the foreground,
// such as when the screen is locked or a remote session is disconnected.
if len(foregroundUIDs) == 0 && isCurrentUserSingedIn {
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
return "", "", false
}
// Shutdown implements [localBackendExtension].
func (e *desktopSessionsExt) Shutdown() error {
for _, f := range e.cleanup {
f()
}
e.cleanup = nil
e.LocalBackend = nil
return nil
}