tailscale/ipn/ipnext/ipnext.go

285 lines
13 KiB
Go
Raw Normal View History

cmd/tailscaled,ipn/{auditlog,desktop,ipnext,ipnlocal},tsd: extract LocalBackend extension interfaces and implementation In this PR, we refactor the LocalBackend extension system, moving from direct callbacks to a more organized extension host model. Specifically, we: - Extract interface and callback types used by packages extending LocalBackend functionality into a new ipn/ipnext package. - Define ipnext.Host as a new interface that bridges extensions with LocalBackend. It enables extensions to register callbacks and interact with LocalBackend in a concurrency-safe, well-defined, and controlled way. - Move existing callback registration and invocation code from ipnlocal.LocalBackend into a new type called ipnlocal.ExtensionHost, implementing ipnext.Host. - Improve docs for existing types and methods while adding docs for the new interfaces. - Add test coverage for both the extracted and the new code. - Remove ipn/desktop.SessionManager from tsd.System since ipn/desktop is now self-contained. - Update existing extensions (e.g., ipn/auditlog and ipn/desktop) to use the new interfaces where appropriate. We're not introducing new callback and hook types (e.g., for ipn.Prefs changes) just yet, nor are we enhancing current callbacks, such as by improving conflict resolution when more than one extension tries to influence profile selection via a background profile resolver. These further improvements will be submitted separately. Updates #12614 Updates tailscale/corp#27645 Updates tailscale/corp#26435 Updates tailscale/corp#18342 Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-04-10 20:24:58 -05:00
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package ipnext defines types and interfaces used for extending the core LocalBackend
// functionality with additional features and services.
package ipnext
import (
"errors"
"fmt"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/mak"
)
// Extension augments LocalBackend with additional functionality.
//
// An extension uses the provided [Host] to register callbacks
// and interact with the backend in a controlled, well-defined
// and thread-safe manner.
//
// Extensions are registered using [RegisterExtension].
//
// They must be safe for concurrent use.
type Extension interface {
// Name is a unique name of the extension.
// It must be the same as the name used to register the extension.
Name() string
// Init is called to initialize the extension when LocalBackend is initialized.
// If the extension cannot be initialized, it must return an error,
// and its Shutdown method will not be called on the host's shutdown.
// Returned errors are not fatal; they are used for logging.
// A [SkipExtension] error indicates an intentional decision rather than a failure.
Init(Host) error
// Shutdown is called when LocalBackend is shutting down,
// provided the extension was initialized. For multiple extensions,
// Shutdown is called in the reverse order of Init.
// Returned errors are not fatal; they are used for logging.
Shutdown() error
}
// NewExtensionFn is a function that instantiates an [Extension].
// If a registered extension cannot be instantiated, the function must return an error.
// If the extension should be skipped at runtime, it must return either [SkipExtension]
// or a wrapped [SkipExtension]. Any other error returned is fatal and will prevent
// the LocalBackend from starting.
type NewExtensionFn func(logger.Logf, *tsd.System) (Extension, error)
// SkipExtension is an error returned by [NewExtensionFn] to indicate that the extension
// should be skipped rather than prevent the LocalBackend from starting.
//
// Skipping an extension should be reserved for cases where the extension is not supported
// on the current platform or configuration, or depends on a feature that is not available,
// or otherwise should be disabled permanently rather than temporarily.
//
// Specifically, it must not be returned if the extension is not required right now
// based on user preferences, policy settings, the current tailnet, or other factors
// that may change throughout the LocalBackend's lifetime.
var SkipExtension = errors.New("skipping extension")
// Definition describes a registered [Extension].
type Definition struct {
name string // name under which the extension is registered
newFn NewExtensionFn // function that creates a new instance of the extension
}
// Name returns the name of the extension.
func (d *Definition) Name() string {
return d.name
}
// MakeExtension instantiates the extension.
func (d *Definition) MakeExtension(logf logger.Logf, sys *tsd.System) (Extension, error) {
ext, err := d.newFn(logf, sys)
if err != nil {
return nil, err
}
if ext.Name() != d.name {
return nil, fmt.Errorf("extension name mismatch: registered %q; actual %q", d.name, ext.Name())
}
return ext, nil
}
// extensionsByName is a map of registered extensions,
// where the key is the name of the extension.
var extensionsByName map[string]*Definition
// extensionsByOrder is a slice of registered extensions,
// in the order they were registered.
var extensionsByOrder []*Definition
// RegisterExtension registers a function that instantiates an [Extension].
// The name must be the same as returned by the extension's [Extension.Name].
//
// It must be called on the main goroutine before LocalBackend is created,
// such as from an init function of the package implementing the extension.
//
// It panics if newExt is nil or if an extension with the same name
// has already been registered.
func RegisterExtension(name string, newExt NewExtensionFn) {
if newExt == nil {
panic(fmt.Sprintf("ipnext: newExt is nil: %q", name))
}
if _, ok := extensionsByName[name]; ok {
panic(fmt.Sprintf("ipnext: duplicate extensions: %q", name))
}
ext := &Definition{name, newExt}
mak.Set(&extensionsByName, name, ext)
extensionsByOrder = append(extensionsByOrder, ext)
}
// Extensions returns a read-only view of the extensions
// registered via [RegisterExtension]. It preserves the order
// in which the extensions were registered.
func Extensions() views.Slice[*Definition] {
return views.SliceOf(extensionsByOrder)
}
// DefinitionForTest returns a [Definition] for the specified [Extension].
// It is primarily used for testing where the test code needs to instantiate
// and use an extension without registering it.
func DefinitionForTest(ext Extension) *Definition {
return &Definition{
name: ext.Name(),
newFn: func(logger.Logf, *tsd.System) (Extension, error) { return ext, nil },
}
}
// DefinitionWithErrForTest returns a [Definition] with the specified extension name
// whose [Definition.MakeExtension] method returns the specified error.
// It is used for testing.
func DefinitionWithErrForTest(name string, err error) *Definition {
return &Definition{
name: name,
newFn: func(logger.Logf, *tsd.System) (Extension, error) { return nil, err },
}
}
// Host is the API surface used by [Extension]s to interact with LocalBackend
// in a controlled manner.
//
// Extensions can register callbacks, request information, or perform actions
// via the [Host] interface.
//
// Typically, the host invokes registered callbacks when one of the following occurs:
// - LocalBackend notifies it of an event or state change that may be
// of interest to extensions, such as when switching [ipn.LoginProfile].
// - LocalBackend needs to consult extensions for information, for example,
// determining the most appropriate profile for the current state of the system.
// - LocalBackend performs an extensible action, such as logging an auditable event,
// and delegates its execution to the extension.
//
// The callbacks are invoked synchronously, and the LocalBackend's state
// remains unchanged while callbacks execute.
//
// In contrast, actions initiated by extensions are generally asynchronous,
// as indicated by the "Async" suffix in their names.
// Performing actions may result in callbacks being invoked as described above.
//
// To prevent conflicts between extensions competing for shared state,
// such as the current profile or prefs, the host must not expose methods
// that directly modify that state. For example, instead of allowing extensions
// to switch profiles at-will, the host's [ProfileServices] provides a method
// to switch to the "best" profile. The host can then consult extensions
// to determine the appropriate profile to use and resolve any conflicts
// in a controlled manner.
//
// A host must be safe for concurrent use.
type Host interface {
// Profiles returns the host's [ProfileServices].
Profiles() ProfileServices
// RegisterAuditLogProvider registers an audit log provider,
// which returns a function to be called when an auditable action
// is about to be performed. The returned function unregisters the provider.
// It is a runtime error to register a nil provider.
RegisterAuditLogProvider(AuditLogProvider) (unregister func())
// AuditLogger returns a function that calls all currently registered audit loggers.
// The function fails if any logger returns an error, indicating that the action
// cannot be logged and must not be performed.
//
// The returned function captures the current state (e.g., the current profile) at
// the time of the call and must not be persisted.
AuditLogger() ipnauth.AuditLogFunc
// RegisterControlClientCallback registers a function to be called every time a new
// control client is created. The returned function unregisters the callback.
// It is a runtime error to register a nil callback.
RegisterControlClientCallback(NewControlClientCallback) (unregister func())
}
// ProfileServices provides access to the [Host]'s profile management services,
// such as switching profiles and registering profile change callbacks.
type ProfileServices interface {
// SwitchToBestProfileAsync asynchronously selects the best profile to use
// and switches to it, unless it is already the current profile.
//
// If an extension needs to know when a profile switch occurs,
// it must use [ProfileServices.RegisterProfileChangeCallback]
// to register a [ProfileChangeCallback].
//
// The reason indicates why the profile is being switched, such as due
// to a client connecting or disconnecting or a change in the desktop
// session state. It is used for logging.
SwitchToBestProfileAsync(reason string)
// RegisterBackgroundProfileResolver registers a function to be used when
// resolving the background profile. The returned function unregisters the resolver.
// It is a runtime error to register a nil resolver.
//
// TODO(nickkhyl): allow specifying some kind of priority/altitude for the resolver.
// TODO(nickkhyl): make it a "profile resolver" instead of a "background profile resolver".
// The concepts of the "current user", "foreground profile" and "background profile"
// only exist on Windows, and we're moving away from them anyway.
RegisterBackgroundProfileResolver(ProfileResolver) (unregister func())
// RegisterProfileChangeCallback registers a function to be called when the current
// [ipn.LoginProfile] changes. The returned function unregisters the callback.
// It is a runtime error to register a nil callback.
RegisterProfileChangeCallback(ProfileChangeCallback) (unregister func())
}
// ProfileStore provides read-only access to available login profiles and their preferences.
// It is not safe for concurrent use and can only be used from the callback it is passed to.
type ProfileStore interface {
// CurrentUserID returns the current user ID. It is only non-empty on
// Windows where we have a multi-user system.
//
// Deprecated: this method exists for compatibility with the current (as of 2024-08-27)
// permission model and will be removed as we progress on tailscale/corp#18342.
CurrentUserID() ipn.WindowsUserID
// CurrentProfile returns a read-only [ipn.LoginProfileView] of the current profile.
// The returned view is always valid, but the profile's [ipn.LoginProfileView.ID]
// returns "" if the profile is new and has not been persisted yet.
CurrentProfile() ipn.LoginProfileView
// CurrentPrefs returns a read-only view of the current prefs.
// The returned view is always valid.
CurrentPrefs() ipn.PrefsView
// DefaultUserProfile returns a read-only view of the default (last used) profile for the specified user.
// It returns a read-only view of a new, non-persisted profile if the specified user does not have a default profile.
DefaultUserProfile(uid ipn.WindowsUserID) ipn.LoginProfileView
}
// AuditLogProvider is a function that returns an [ipnauth.AuditLogFunc] for
// logging auditable actions.
type AuditLogProvider func() ipnauth.AuditLogFunc
// ProfileResolver is a function that returns a read-only view of a login profile.
// An invalid view indicates no profile. A valid profile view with an empty [ipn.ProfileID]
// indicates that the profile is new and has not been persisted yet.
// The provided [ProfileStore] can only be used for the duration of the callback.
type ProfileResolver func(ProfileStore) ipn.LoginProfileView
// ProfileChangeCallback is a function to be called when the current login profile changes.
// The sameNode parameter indicates whether the profile represents the same node as before,
// such as when only the profile metadata is updated but the node ID remains the same,
// or when a new profile is persisted and assigned an [ipn.ProfileID] for the first time.
// The subscribers can use this information to decide whether to reset their state.
//
// The profile and prefs are always valid, but the profile's [ipn.LoginProfileView.ID]
// returns "" if the profile is new and has not been persisted yet.
type ProfileChangeCallback func(_ ipn.LoginProfileView, _ ipn.PrefsView, sameNode bool)
// NewControlClientCallback is a function to be called when a new [controlclient.Client]
// is created and before it is first used. The login profile and prefs represent
// the profile for which the cc is created and are always valid; however, the
// profile's [ipn.LoginProfileView.ID] returns "" if the profile is new
// and has not been persisted yet. If the [controlclient.Client] is created
// due to a profile switch, any registered [ProfileChangeCallback]s are called first.
//
// It returns a function to be called when the cc is being shut down,
// or nil if no cleanup is needed.
type NewControlClientCallback func(controlclient.Client, ipn.LoginProfileView, ipn.PrefsView) (cleanup func())