mirror of
https://github.com/tailscale/tailscale.git
synced 2025-11-08 21:33:00 +00:00
In this PR, we update client/tailscale.LocalClient to allow sending requests with an optional X-Tailscale-Reason
header. We then update ipn/ipnserver.{actor,Server} to retrieve this reason, if specified, and use it to determine
whether ipnauth.Disconnect is allowed when the AlwaysOn.OverrideWithReason policy setting is enabled.
For now, we log the reason, along with the profile and OS username, to the backend log.
Finally, we update LocalBackend to remember when a disconnect was permitted and do not reconnect automatically
unless the policy changes.
Updates tailscale/corp#26146
Signed-off-by: Nick Khyl <nickk@tailscale.com>
257 lines
8.0 KiB
Go
257 lines
8.0 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package ipnserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os/exec"
|
|
"runtime"
|
|
"time"
|
|
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/ipnauth"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/ctxkey"
|
|
"tailscale.com/util/osuser"
|
|
"tailscale.com/util/syspolicy"
|
|
"tailscale.com/version"
|
|
)
|
|
|
|
var _ ipnauth.Actor = (*actor)(nil)
|
|
|
|
// actor implements [ipnauth.Actor] and provides additional functionality that is
|
|
// specific to the current (as of 2024-08-27) permission model.
|
|
//
|
|
// Deprecated: this type exists for compatibility reasons and will be removed as
|
|
// we progress on tailscale/corp#18342.
|
|
type actor struct {
|
|
logf logger.Logf
|
|
ci *ipnauth.ConnIdentity
|
|
|
|
clientID ipnauth.ClientID
|
|
// accessOverrideReason specifies the reason for overriding certain access restrictions,
|
|
// such as permitting a user to disconnect when the always-on mode is enabled,
|
|
// provided that such justification is allowed by the policy.
|
|
accessOverrideReason string
|
|
isLocalSystem bool // whether the actor is the Windows' Local System identity.
|
|
}
|
|
|
|
func newActor(logf logger.Logf, c net.Conn) (*actor, error) {
|
|
ci, err := ipnauth.GetConnIdentity(logf, c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var clientID ipnauth.ClientID
|
|
if pid := ci.Pid(); pid != 0 {
|
|
// Derive [ipnauth.ClientID] from the PID of the connected client process.
|
|
// TODO(nickkhyl): This is transient and will be re-worked as we
|
|
// progress on tailscale/corp#18342. At minimum, we should use a 2-tuple
|
|
// (PID + StartTime) or a 3-tuple (PID + StartTime + UID) to identify
|
|
// the client process. This helps prevent security issues where a
|
|
// terminated client process's PID could be reused by a different
|
|
// process. This is not currently an issue as we allow only one user to
|
|
// connect anyway.
|
|
// Additionally, we should consider caching authentication results since
|
|
// operations like retrieving a username by SID might require network
|
|
// connectivity on domain-joined devices and/or be slow.
|
|
clientID = ipnauth.ClientIDFrom(pid)
|
|
}
|
|
return &actor{logf: logf, ci: ci, clientID: clientID, isLocalSystem: connIsLocalSystem(ci)}, nil
|
|
}
|
|
|
|
// actorWithAccessOverride returns a new actor that carries the specified
|
|
// reason for overriding certain access restrictions, if permitted by the
|
|
// policy. If the reason is "", it returns the base actor.
|
|
func actorWithAccessOverride(baseActor *actor, reason string) *actor {
|
|
if reason == "" {
|
|
return baseActor
|
|
}
|
|
return &actor{
|
|
logf: baseActor.logf,
|
|
ci: baseActor.ci,
|
|
clientID: baseActor.clientID,
|
|
accessOverrideReason: reason,
|
|
isLocalSystem: baseActor.isLocalSystem,
|
|
}
|
|
}
|
|
|
|
// CheckProfileAccess implements [ipnauth.Actor].
|
|
func (a *actor) CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess ipnauth.ProfileAccess) error {
|
|
// TODO(nickkhyl): return errors of more specific types and have them
|
|
// translated to the appropriate HTTP status codes in the API handler.
|
|
if profile.LocalUserID() != a.UserID() {
|
|
return errors.New("the target profile does not belong to the user")
|
|
}
|
|
switch requestedAccess {
|
|
case ipnauth.Disconnect:
|
|
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); alwaysOn {
|
|
if allowWithReason, _ := syspolicy.GetBoolean(syspolicy.AlwaysOnOverrideWithReason, false); !allowWithReason {
|
|
return errors.New("disconnect not allowed: always-on mode is enabled")
|
|
}
|
|
if a.accessOverrideReason == "" {
|
|
return errors.New("disconnect not allowed: reason required")
|
|
}
|
|
maybeUsername, _ := a.Username() // best-effort
|
|
a.logf("Tailscale (%q) is being disconnected by %q: %v", profile.Name(), maybeUsername, a.accessOverrideReason)
|
|
// TODO(nickkhyl): Log the reason to the audit log once we have one.
|
|
}
|
|
return nil // disconnect is allowed
|
|
default:
|
|
return errors.New("the requested operation is not allowed")
|
|
}
|
|
}
|
|
|
|
// IsLocalSystem implements [ipnauth.Actor].
|
|
func (a *actor) IsLocalSystem() bool {
|
|
return a.isLocalSystem
|
|
}
|
|
|
|
// IsLocalAdmin implements [ipnauth.Actor].
|
|
func (a *actor) IsLocalAdmin(operatorUID string) bool {
|
|
return a.isLocalSystem || connIsLocalAdmin(a.logf, a.ci, operatorUID)
|
|
}
|
|
|
|
// UserID implements [ipnauth.Actor].
|
|
func (a *actor) UserID() ipn.WindowsUserID {
|
|
return a.ci.WindowsUserID()
|
|
}
|
|
|
|
func (a *actor) pid() int {
|
|
return a.ci.Pid()
|
|
}
|
|
|
|
// ClientID implements [ipnauth.Actor].
|
|
func (a *actor) ClientID() (_ ipnauth.ClientID, ok bool) {
|
|
return a.clientID, a.clientID != ipnauth.NoClientID
|
|
}
|
|
|
|
// Username implements [ipnauth.Actor].
|
|
func (a *actor) Username() (string, error) {
|
|
if a.ci == nil {
|
|
a.logf("[unexpected] missing ConnIdentity in ipnserver.actor")
|
|
return "", errors.New("missing ConnIdentity")
|
|
}
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
tok, err := a.ci.WindowsToken()
|
|
if err != nil {
|
|
return "", fmt.Errorf("get windows token: %w", err)
|
|
}
|
|
defer tok.Close()
|
|
return tok.Username()
|
|
case "darwin", "linux", "illumos", "solaris":
|
|
uid, ok := a.ci.Creds().UserID()
|
|
if !ok {
|
|
return "", errors.New("missing user ID")
|
|
}
|
|
u, err := osuser.LookupByUID(uid)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup user: %w", err)
|
|
}
|
|
return u.Username, nil
|
|
default:
|
|
return "", errors.New("unsupported OS")
|
|
}
|
|
}
|
|
|
|
type actorOrError struct {
|
|
actor ipnauth.Actor
|
|
err error
|
|
}
|
|
|
|
func (a actorOrError) unwrap() (ipnauth.Actor, error) {
|
|
return a.actor, a.err
|
|
}
|
|
|
|
var errNoActor = errors.New("connection actor not available")
|
|
|
|
var actorKey = ctxkey.New("ipnserver.actor", actorOrError{err: errNoActor})
|
|
|
|
// contextWithActor returns a new context that carries the identity of the actor
|
|
// owning the other end of the [net.Conn]. It can be retrieved with [actorFromContext].
|
|
func contextWithActor(ctx context.Context, logf logger.Logf, c net.Conn) context.Context {
|
|
actor, err := newActor(logf, c)
|
|
return actorKey.WithValue(ctx, actorOrError{actor: actor, err: err})
|
|
}
|
|
|
|
// actorFromContext returns an [ipnauth.Actor] associated with ctx,
|
|
// or an error if the context does not carry an actor's identity.
|
|
func actorFromContext(ctx context.Context) (ipnauth.Actor, error) {
|
|
return actorKey.Value(ctx).unwrap()
|
|
}
|
|
|
|
func connIsLocalSystem(ci *ipnauth.ConnIdentity) bool {
|
|
token, err := ci.WindowsToken()
|
|
return err == nil && token.IsLocalSystem()
|
|
}
|
|
|
|
// connIsLocalAdmin reports whether the connected client has administrative
|
|
// access to the local machine, for whatever that means with respect to the
|
|
// current OS.
|
|
//
|
|
// This is useful because tailscaled itself always runs with elevated rights:
|
|
// we want to avoid privilege escalation for certain mutative operations.
|
|
func connIsLocalAdmin(logf logger.Logf, ci *ipnauth.ConnIdentity, operatorUID string) bool {
|
|
if ci == nil {
|
|
logf("[unexpected] missing ConnIdentity in LocalAPI Handler")
|
|
return false
|
|
}
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
tok, err := ci.WindowsToken()
|
|
if err != nil {
|
|
if !errors.Is(err, ipnauth.ErrNotImplemented) {
|
|
logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err)
|
|
}
|
|
return false
|
|
}
|
|
defer tok.Close()
|
|
|
|
return tok.IsElevated()
|
|
|
|
case "darwin":
|
|
// Unknown, or at least unchecked on sandboxed macOS variants. Err on
|
|
// the side of less permissions.
|
|
//
|
|
// authorizeServeConfigForGOOSAndUserContext should not call
|
|
// connIsLocalAdmin on sandboxed variants anyway.
|
|
if version.IsSandboxedMacOS() {
|
|
return false
|
|
}
|
|
// This is a standalone tailscaled setup, use the same logic as on
|
|
// Linux.
|
|
fallthrough
|
|
case "linux":
|
|
uid, ok := ci.Creds().UserID()
|
|
if !ok {
|
|
return false
|
|
}
|
|
// root is always admin.
|
|
if uid == "0" {
|
|
return true
|
|
}
|
|
// if non-root, must be operator AND able to execute "sudo tailscale".
|
|
if operatorUID != "" && uid != operatorUID {
|
|
return false
|
|
}
|
|
u, err := osuser.LookupByUID(uid)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Short timeout just in case sudo hangs for some reason.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := exec.CommandContext(ctx, "sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|