diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index b9082f966..2e96f03d0 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -814,6 +814,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/internal/noiseconn from tailscale.com/control/controlclient tailscale.com/ipn from tailscale.com/client/local+ tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+ + 💣 tailscale.com/ipn/desktop from tailscale.com/ipn/ipnlocal+ 💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/ipnlocal from tailscale.com/ipn/localapi+ tailscale.com/ipn/ipnstate from tailscale.com/client/local+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 3eaa12d16..594ebeb17 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -272,6 +272,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/internal/noiseconn from tailscale.com/control/controlclient tailscale.com/ipn from tailscale.com/client/local+ tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+ + 💣 tailscale.com/ipn/desktop from tailscale.com/cmd/tailscaled+ 💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/ipnlocal from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled diff --git a/cmd/tailscaled/tailscaled_windows.go b/cmd/tailscaled/tailscaled_windows.go index 7208e03da..3574fb5f4 100644 --- a/cmd/tailscaled/tailscaled_windows.go +++ b/cmd/tailscaled/tailscaled_windows.go @@ -44,6 +44,7 @@ "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" "tailscale.com/drive/driveimpl" "tailscale.com/envknob" + "tailscale.com/ipn/desktop" "tailscale.com/logpolicy" "tailscale.com/logtail/backoff" "tailscale.com/net/dns" @@ -335,6 +336,13 @@ func beWindowsSubprocess() bool { sys.Set(driveimpl.NewFileSystemForRemote(log.Printf)) + if sessionManager, err := desktop.NewSessionManager(log.Printf); err == nil { + sys.Set(sessionManager) + } else { + // Errors creating the session manager are unexpected, but not fatal. + log.Printf("[unexpected]: error creating a desktop session manager: %v", err) + } + publicLogID, _ := logid.ParsePublicID(logID) err = startIPNServer(ctx, log.Printf, publicLogID, sys) if err != nil { diff --git a/ipn/desktop/sessions_windows.go b/ipn/desktop/sessions_windows.go index f1b88d573..b26172d77 100644 --- a/ipn/desktop/sessions_windows.go +++ b/ipn/desktop/sessions_windows.go @@ -359,7 +359,7 @@ func (sw *sessionWatcher) Start() error { sw.doneCh = make(chan error, 1) startedCh := make(chan error, 1) - go sw.run(startedCh) + go sw.run(startedCh, sw.doneCh) if err := <-startedCh; err != nil { return err } @@ -372,11 +372,11 @@ func (sw *sessionWatcher) Start() error { return nil } -func (sw *sessionWatcher) run(started chan<- error) { +func (sw *sessionWatcher) run(started, done chan<- error) { runtime.LockOSThread() defer func() { runtime.UnlockOSThread() - close(sw.doneCh) + close(done) }() err := sw.createMessageWindow() started <- err diff --git a/ipn/ipnlocal/desktop_sessions.go b/ipn/ipnlocal/desktop_sessions.go new file mode 100644 index 000000000..23307f667 --- /dev/null +++ b/ipn/ipnlocal/desktop_sessions.go @@ -0,0 +1,178 @@ +// 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 +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 3cd8d3c99..8b30484d6 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -168,6 +168,49 @@ type watchSession struct { cancel context.CancelFunc // to shut down the session } +// localBackendExtension extends [LocalBackend] with additional functionality. +type localBackendExtension interface { + // Init is called to initialize the extension when the [LocalBackend] is created + // and before it starts running. If the extension cannot be initialized, + // it must return an error, and the Shutdown method will not be called. + // Any returned errors are not fatal; they are used for logging. + // TODO(nickkhyl): should we allow returning a fatal error? + Init(*LocalBackend) error + + // Shutdown is called when the [LocalBackend] is shutting down, + // if the extension was initialized. Any returned errors are not fatal; + // they are used for logging. + Shutdown() error +} + +// newLocalBackendExtension is a function that instantiates a [localBackendExtension]. +type newLocalBackendExtension func(logger.Logf, *tsd.System) (localBackendExtension, error) + +// registeredExtensions is a map of registered local backend extensions, +// where the key is the name of the extension and the value is the function +// that instantiates the extension. +var registeredExtensions map[string]newLocalBackendExtension + +// RegisterExtension registers a function that creates a [localBackendExtension]. +// It panics if newExt is nil or if an extension with the same name has already been registered. +func RegisterExtension(name string, newExt newLocalBackendExtension) { + if newExt == nil { + panic(fmt.Sprintf("lb: newExt is nil: %q", name)) + } + if _, ok := registeredExtensions[name]; ok { + panic(fmt.Sprintf("lb: duplicate extensions: %q", name)) + } + mak.Set(®isteredExtensions, name, newExt) +} + +// profileResolver is any function that returns user and profile IDs +// along with a flag indicating whether it succeeded. Since an empty +// profile ID ("") represents an empty profile, the ok return parameter +// distinguishes between an empty profile and no profile. +// +// It is called with [LocalBackend.mu] held. +type profileResolver func() (_ ipn.WindowsUserID, _ ipn.ProfileID, ok bool) + // LocalBackend is the glue between the major pieces of the Tailscale // network software: the cloud control plane (via controlclient), the // network data plane (via wgengine), and the user-facing UIs and CLIs @@ -302,8 +345,12 @@ type LocalBackend struct { directFileRoot string componentLogUntil map[string]componentLogState // c2nUpdateStatus is the status of c2n-triggered client update. - c2nUpdateStatus updateStatus - currentUser ipnauth.Actor + c2nUpdateStatus updateStatus + currentUser ipnauth.Actor + + // backgroundProfileResolvers are optional background profile resolvers. + backgroundProfileResolvers set.HandleSet[profileResolver] + selfUpdateProgress []ipnstate.UpdateProgress lastSelfUpdateState ipnstate.SelfUpdateStatus // capForcedNetfilter is the netfilter that control instructs Linux clients @@ -394,6 +441,11 @@ type LocalBackend struct { // and the user has disconnected with a reason. // See tailscale/corp#26146. overrideAlwaysOn bool + + // shutdownCbs are the callbacks to be called when the backend is shutting down. + // Each callback is called exactly once in unspecified order and without b.mu held. + // Returned errors are logged but otherwise ignored and do not affect the shutdown process. + shutdownCbs set.HandleSet[func() error] } // HealthTracker returns the health tracker for the backend. @@ -575,6 +627,19 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo } } + for name, newFn := range registeredExtensions { + ext, err := newFn(logf, sys) + if err != nil { + b.logf("lb: failed to create %q extension: %v", name, err) + continue + } + if err := ext.Init(b); err != nil { + b.logf("lb: failed to initialize %q extension: %v", name, err) + continue + } + b.shutdownCbs.Add(ext.Shutdown) + } + return b, nil } @@ -1033,9 +1098,17 @@ func (b *LocalBackend) Shutdown() { if b.notifyCancel != nil { b.notifyCancel() } + shutdownCbs := slices.Collect(maps.Values(b.shutdownCbs)) + b.shutdownCbs = nil b.mu.Unlock() b.webClientShutdown() + for _, cb := range shutdownCbs { + if err := cb(); err != nil { + b.logf("shutdown callback failed: %v", err) + } + } + if b.sockstatLogger != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -3826,13 +3899,18 @@ func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) { b.switchToBestProfileLockedOnEntry(reason, unlock) } -// switchToBestProfileLockedOnEntry selects the best profile to use, +// SwitchToBestProfile 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. +// or disconnecting, or a change in the desktop session state, and is used +// for logging. +func (b *LocalBackend) SwitchToBestProfile(reason string) { + b.switchToBestProfileLockedOnEntry(reason, b.lockAndGetUnlock()) +} + +// switchToBestProfileLockedOnEntry is like [LocalBackend.SwitchToBestProfile], +// but b.mu must held on entry. It is released on exit. func (b *LocalBackend) switchToBestProfileLockedOnEntry(reason string, unlock unlockOnce) { defer unlock() oldControlURL := b.pm.CurrentPrefs().ControlURLOrDefault() @@ -3867,8 +3945,9 @@ func (b *LocalBackend) switchToBestProfileLockedOnEntry(reason string, unlock un } // 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. +// state of the backend, such as whether a GUI/CLI client is connected, whether +// the unattended mode is enabled, the current state of the desktop sessions, +// and other factors. // // 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 @@ -3897,7 +3976,8 @@ func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, pro } // Otherwise, if on Windows, use the background profile if one is set. - // This includes staying on the current profile if Unattended Mode is enabled. + // This includes staying on the current profile if Unattended Mode is enabled + // or if AlwaysOn mode is enabled and the current user is still signed in. // 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" { @@ -3914,14 +3994,41 @@ func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, pro return b.pm.CurrentUserID(), b.pm.CurrentProfile().ID(), false } +// RegisterBackgroundProfileResolver registers a function to be used when +// resolving the background profile, until the returned unregister function is called. +func (b *LocalBackend) RegisterBackgroundProfileResolver(resolver profileResolver) (unregister func()) { + // TODO(nickkhyl): should we allow specifying some kind of priority/altitude for the resolver? + b.mu.Lock() + defer b.mu.Unlock() + handle := b.backgroundProfileResolvers.Add(resolver) + return func() { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.backgroundProfileResolvers, handle) + } +} + // 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) getBackgroundProfileLocked() (ipn.WindowsUserID, ipn.ProfileID) { + // TODO(nickkhyl): check if the returned profile is allowed on the device, + // such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet. + // See tailscale/corp#26249. + // 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, attempt to resolve the background profile using the background + // profile resolvers available on the current platform. + for _, resolver := range b.backgroundProfileResolvers { + if uid, profileID, ok := resolver(); ok { + return uid, profileID + } + } + // Otherwise, switch to an empty profile and disconnect Tailscale // until a GUI or CLI client connects. return "", "" diff --git a/ipn/ipnserver/actor.go b/ipn/ipnserver/actor.go index b0245b0a8..9c203fc5f 100644 --- a/ipn/ipnserver/actor.go +++ b/ipn/ipnserver/actor.go @@ -81,6 +81,7 @@ func actorWithAccessOverride(baseActor *actor, reason string) *actor { logf: baseActor.logf, ci: baseActor.ci, clientID: baseActor.clientID, + userID: baseActor.userID, accessOverrideReason: reason, isLocalSystem: baseActor.isLocalSystem, } diff --git a/tsd/tsd.go b/tsd/tsd.go index acd09560c..1d1f35017 100644 --- a/tsd/tsd.go +++ b/tsd/tsd.go @@ -26,6 +26,7 @@ "tailscale.com/health" "tailscale.com/ipn" "tailscale.com/ipn/conffile" + "tailscale.com/ipn/desktop" "tailscale.com/net/dns" "tailscale.com/net/netmon" "tailscale.com/net/tsdial" @@ -52,6 +53,7 @@ type System struct { Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl DriveForLocal SubSystem[drive.FileSystemForLocal] DriveForRemote SubSystem[drive.FileSystemForRemote] + SessionManager SubSystem[desktop.SessionManager] // InitialConfig is initial server config, if any. // It is nil if the node is not in declarative mode. @@ -110,6 +112,8 @@ type ft interface { s.DriveForLocal.Set(v) case drive.FileSystemForRemote: s.DriveForRemote.Set(v) + case desktop.SessionManager: + s.SessionManager.Set(v) default: panic(fmt.Sprintf("unknown type %T", v)) } diff --git a/tstest/integration/tailscaled_deps_test_windows.go b/tstest/integration/tailscaled_deps_test_windows.go index 6ea475e64..a6df2f9ff 100644 --- a/tstest/integration/tailscaled_deps_test_windows.go +++ b/tstest/integration/tailscaled_deps_test_windows.go @@ -29,6 +29,7 @@ _ "tailscale.com/hostinfo" _ "tailscale.com/ipn" _ "tailscale.com/ipn/conffile" + _ "tailscale.com/ipn/desktop" _ "tailscale.com/ipn/ipnlocal" _ "tailscale.com/ipn/ipnserver" _ "tailscale.com/ipn/store"