mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-15 23:42:48 -04:00
This registers per-user RSOP policy stores when a user logs into a Windows session and ensures that per-user poicy settings are delivered to clients via IPN bus. The store lifecycle is tied to Windows session lifetime via desktopSessionExt's SessionInitCallback, which handles unattended mode (per-user policies stay enforced after gui disconnection) and multi-user (refcounted across sessions for the same user). Updates tailscale/corp#42259 Signed-off-by: kari-ts <kari@tailscale.com>
302 lines
9.2 KiB
Go
302 lines
9.2 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// 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 desktop
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"golang.org/x/sys/windows"
|
|
"tailscale.com/feature"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/ipnext"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/syspolicy/pkey"
|
|
"tailscale.com/util/syspolicy/policyclient"
|
|
"tailscale.com/util/syspolicy/rsop"
|
|
"tailscale.com/util/syspolicy/setting"
|
|
"tailscale.com/util/syspolicy/source"
|
|
)
|
|
|
|
// featureName is the name of the feature implemented by this package.
|
|
// It is also the [desktopSessionsExt] name and the log prefix.
|
|
const featureName = "desktop-sessions"
|
|
|
|
func init() {
|
|
feature.Register(featureName)
|
|
ipnext.RegisterExtension(featureName, newDesktopSessionsExt)
|
|
}
|
|
|
|
// [desktopSessionsExt] implements [ipnext.Extension].
|
|
var _ ipnext.Extension = (*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 SessionManager
|
|
|
|
host ipnext.Host // or nil, until Init is called
|
|
cleanup []func() // cleanup functions to call on shutdown
|
|
|
|
// mu protects all following fields.
|
|
mu sync.Mutex
|
|
sessByID map[SessionID]*Session
|
|
|
|
// userPolicyStores tracks per-user RSOP policy store registrations,
|
|
// refcounted by the number of active sessions for each user
|
|
userPolicyStores map[string]*userPolicyStore
|
|
}
|
|
|
|
type userPolicyStore struct {
|
|
refcount int
|
|
store source.Store
|
|
reg *rsop.StoreRegistration
|
|
}
|
|
|
|
func closePolicyStore(store source.Store) {
|
|
switch store := store.(type) {
|
|
case interface{ Close() error }:
|
|
_ = store.Close()
|
|
case interface{ Close() }:
|
|
store.Close()
|
|
}
|
|
}
|
|
|
|
// newDesktopSessionsExt returns a new [desktopSessionsExt],
|
|
// or an error if a [SessionManager] cannot be created.
|
|
// It is registered with [ipnext.RegisterExtension] if the package is imported.
|
|
func newDesktopSessionsExt(logf logger.Logf, _ ipnext.SafeBackend) (ipnext.Extension, error) {
|
|
logf = logger.WithPrefix(logf, featureName+": ")
|
|
sm, err := NewSessionManager(logf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: session manager is not available: %w", ipnext.SkipExtension, err)
|
|
}
|
|
return &desktopSessionsExt{
|
|
logf: logf,
|
|
sm: sm,
|
|
sessByID: make(map[SessionID]*Session),
|
|
}, nil
|
|
}
|
|
|
|
// Name implements [ipnext.Extension].
|
|
func (e *desktopSessionsExt) Name() string {
|
|
return featureName
|
|
}
|
|
|
|
// Init implements [ipnext.Extension].
|
|
func (e *desktopSessionsExt) Init(host ipnext.Host) (err error) {
|
|
e.host = host
|
|
unregisterSessionCb, err := e.sm.RegisterStateCallback(e.updateDesktopSessionState)
|
|
if err != nil {
|
|
return fmt.Errorf("session callback registration failed: %w", err)
|
|
}
|
|
unregisterPolicyCb, err := e.sm.RegisterInitCallback(e.initUserPolicyStore)
|
|
if err != nil {
|
|
return fmt.Errorf("policy store callback registration failed: %w", err)
|
|
}
|
|
host.Hooks().BackgroundProfileResolvers.Add(e.getBackgroundProfile)
|
|
e.cleanup = []func(){unregisterSessionCb, unregisterPolicyCb}
|
|
return nil
|
|
}
|
|
|
|
// updateDesktopSessionState is a [SessionStateCallback]
|
|
// invoked by [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 *Session) {
|
|
e.mu.Lock()
|
|
if session.Status != ClosedSession {
|
|
e.sessByID[session.ID] = session
|
|
} else {
|
|
delete(e.sessByID, session.ID)
|
|
}
|
|
e.mu.Unlock()
|
|
|
|
var action string
|
|
switch session.Status {
|
|
case 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 BackgroundSession:
|
|
action = "locked"
|
|
case 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.host.Profiles().SwitchToBestProfileAsync(reason)
|
|
}
|
|
|
|
// initUserPolicyStore is a [SessionInitCallback] that registers a
|
|
// per-user policy store when a user logs in. The returned cleanup
|
|
// function releases the store when the session is destroyed.
|
|
func (e *desktopSessionsExt) initUserPolicyStore(session *Session) func() {
|
|
uid := string(session.User.UserID())
|
|
if uid == "" {
|
|
return nil
|
|
}
|
|
|
|
var token windows.Token
|
|
if err := windows.WTSQueryUserToken(uint32(session.ID), &token); err != nil {
|
|
e.logf("failed to get user token for session %d (uid %s): %v", session.ID, uid, err)
|
|
return nil
|
|
}
|
|
defer token.Close()
|
|
|
|
store, err := source.NewUserPlatformPolicyStore(token)
|
|
if err != nil {
|
|
e.logf("failed to create user policy store for %s: %v", uid, err)
|
|
return nil
|
|
}
|
|
|
|
if err := e.ensureUserPolicyStore(uid, store); err != nil {
|
|
e.logf("failed to register user policy store for %s: %v", uid, err)
|
|
return nil
|
|
}
|
|
|
|
e.logf("registered user policy store for %s (session %d)", uid, session.ID)
|
|
return func() {
|
|
e.releaseUserPolicyStore(uid)
|
|
e.logf("released user policy store for %s (session %d)", uid, session.ID)
|
|
}
|
|
}
|
|
|
|
// ensureUserPolicyStore registers a per-user policy store with RSOP.
|
|
// If a store is already registered for this user (from another session),
|
|
// it increments the refcount and closes the provided store.
|
|
func (e *desktopSessionsExt) ensureUserPolicyStore(uid string, store source.Store) error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if ups, ok := e.userPolicyStores[uid]; ok {
|
|
ups.refcount++
|
|
closePolicyStore(store)
|
|
return nil
|
|
}
|
|
|
|
scope := setting.UserScopeOf(uid)
|
|
reg, err := rsop.RegisterStore("Platform", scope, store)
|
|
if err != nil {
|
|
closePolicyStore(store)
|
|
return err
|
|
}
|
|
|
|
if e.userPolicyStores == nil {
|
|
e.userPolicyStores = make(map[string]*userPolicyStore)
|
|
}
|
|
e.userPolicyStores[uid] = &userPolicyStore{
|
|
refcount: 1,
|
|
store: store,
|
|
reg: reg,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// releaseUserPolicyStore decrements the refcount for a user's policy
|
|
// store. At zero, the store is unregistered from RSOP and closed.
|
|
func (e *desktopSessionsExt) releaseUserPolicyStore(uid string) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
ups, ok := e.userPolicyStores[uid]
|
|
if !ok {
|
|
return
|
|
}
|
|
ups.refcount--
|
|
if ups.refcount > 0 {
|
|
return
|
|
}
|
|
ups.reg.Unregister()
|
|
closePolicyStore(ups.store)
|
|
delete(e.userPolicyStores, uid)
|
|
}
|
|
|
|
// getBackgroundProfile is a [ipnext.ProfileResolver] that works as follows:
|
|
//
|
|
// If Always-On mode is disabled, it returns no profile.
|
|
//
|
|
// If AlwaysOn mode is enabled, it returns the current profile unless:
|
|
// - The current profile's owner has signed out.
|
|
// - Another user has a foreground (i.e. active/unlocked) session.
|
|
//
|
|
// If the current profile owner'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.
|
|
func (e *desktopSessionsExt) getBackgroundProfile(profiles ipnext.ProfileStore) ipn.LoginProfileView {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if alwaysOn, _ := policyclient.Get().GetBoolean(pkey.AlwaysOn, false); !alwaysOn {
|
|
// If the Always-On mode is disabled, there's no background profile
|
|
// as far as the desktop session extension is concerned.
|
|
return ipn.LoginProfileView{}
|
|
}
|
|
|
|
isCurrentProfileOwnerSignedIn := false
|
|
var foregroundUIDs []ipn.WindowsUserID
|
|
for _, s := range e.sessByID {
|
|
switch uid := s.User.UserID(); uid {
|
|
case profiles.CurrentProfile().LocalUserID():
|
|
isCurrentProfileOwnerSignedIn = true
|
|
if s.Status == ForegroundSession {
|
|
// Keep the current profile if the user has a foreground session.
|
|
return profiles.CurrentProfile()
|
|
}
|
|
default:
|
|
if s.Status == ForegroundSession {
|
|
foregroundUIDs = append(foregroundUIDs, uid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the current profile is empty and not owned by anyone (e.g., tailscaled just started),
|
|
// or if the current profile's owner has no foreground session, switch to the default profile
|
|
// of the first user with a foreground session, if any.
|
|
for _, uid := range foregroundUIDs {
|
|
if profile := profiles.DefaultUserProfile(uid); profile.ID() != "" {
|
|
return profile
|
|
}
|
|
}
|
|
|
|
// If no user has a foreground session but the current profile's owner 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 && isCurrentProfileOwnerSignedIn {
|
|
return profiles.CurrentProfile()
|
|
}
|
|
|
|
// Otherwise, there's no background profile.
|
|
return ipn.LoginProfileView{}
|
|
}
|
|
|
|
// Shutdown implements [ipnext.Extension].
|
|
func (e *desktopSessionsExt) Shutdown() error {
|
|
for _, f := range e.cleanup {
|
|
f()
|
|
}
|
|
e.cleanup = nil
|
|
e.host = nil
|
|
return e.sm.Close()
|
|
}
|