mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-28 23:57:52 +09:00
Let fetchSSHAction own the per-fetch deadline and pass the connection cancellation context directly from clientAuth. Remove the duplicate timeout and its deferred cancellation inside the action loop. Derive the longer transport watchdog from the fetch timeout, keeping both durations unchanged. Use virtual time to test the fetch deadline across retries, earlier parent deadlines, and parent cancellation. Verify that a successful fetch releases its request context. Updates tailscale/corp#50380 Change-Id: I185958ca19566f04cbd68f33e4b1dad3d18aebb7 Signed-off-by: James Tucker <james@tailscale.com>
2119 lines
68 KiB
Go
2119 lines
68 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
|
|
|
|
// Package tailssh is an SSH server integrated into Tailscale.
|
|
package tailssh
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
stduser "os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
gliderssh "github.com/tailscale/gliderssh"
|
|
"golang.org/x/crypto/ssh"
|
|
"tailscale.com/envknob"
|
|
"tailscale.com/feature"
|
|
"tailscale.com/ipn/ipnlocal"
|
|
"tailscale.com/net/tsaddr"
|
|
"tailscale.com/net/tsdial"
|
|
"tailscale.com/sessionrecording"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tstime"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/types/netmap"
|
|
"tailscale.com/util/backoff"
|
|
"tailscale.com/util/clientmetric"
|
|
"tailscale.com/util/httpm"
|
|
"tailscale.com/util/mak"
|
|
"tailscale.com/version/distro"
|
|
)
|
|
|
|
var (
|
|
sshVerboseLogging = envknob.RegisterBool("TS_DEBUG_SSH_VLOG")
|
|
sshDisableSFTP = envknob.RegisterBool("TS_SSH_DISABLE_SFTP")
|
|
sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING")
|
|
// sshForwardGatewayPorts permits reverse TCP forwards to bind
|
|
// non-loopback addresses. It is the equivalent of sshd's GatewayPorts.
|
|
sshForwardGatewayPorts = envknob.RegisterBool("TS_SSH_FORWARD_GATEWAY_PORTS")
|
|
sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY")
|
|
|
|
// sshLoginGraceTime is the TS_SSH_LOGIN_GRACE_TIME knob. It is
|
|
// the maximum allowed duration between accepting a connection and
|
|
// completing authentication, like sshd's LoginGraceTime. It defaults
|
|
// to 120 seconds. See loginGraceTime for the accepted value forms.
|
|
sshLoginGraceTime = envknob.RegisterString("TS_SSH_LOGIN_GRACE_TIME")
|
|
|
|
// errTerminal is an empty ssh.PartialSuccessError (with no 'Next'
|
|
// authentication methods that may proceed), which results in the SSH
|
|
// server immediately disconnecting the client.
|
|
errTerminal = &ssh.PartialSuccessError{}
|
|
|
|
// hookSSHLoginSuccess is called after successful SSH authentication.
|
|
// It is set by platform-specific code (e.g., auditd_linux.go).
|
|
hookSSHLoginSuccess feature.Hook[func(logf logger.Logf, c *conn)]
|
|
)
|
|
|
|
const (
|
|
// forcePasswordSuffix is the suffix at the end of a username that forces
|
|
// Tailscale SSH into password authentication mode to work around buggy SSH
|
|
// clients that get confused by successful replies to auth type "none".
|
|
forcePasswordSuffix = "+password"
|
|
)
|
|
|
|
// ipnLocalBackend is the subset of ipnlocal.LocalBackend that we use.
|
|
// It is used for testing.
|
|
type ipnLocalBackend interface {
|
|
ShouldRunSSH() bool
|
|
NetMap() *netmap.NetworkMap
|
|
NetMapNoPeers() *netmap.NetworkMap
|
|
WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
|
DoNoiseRequest(req *http.Request) (*http.Response, error)
|
|
Dialer() *tsdial.Dialer
|
|
TailscaleVarRoot() string
|
|
NodeKey() key.NodePublic
|
|
}
|
|
|
|
type server struct {
|
|
lb ipnLocalBackend
|
|
logf logger.Logf
|
|
tailscaledPath string
|
|
|
|
timeNow func() time.Time // or nil for time.Now
|
|
|
|
sessionWaitGroup sync.WaitGroup
|
|
|
|
// mu protects the following
|
|
mu sync.Mutex
|
|
activeConns map[*conn]bool // set; value is always true
|
|
shutdownCalled bool
|
|
}
|
|
|
|
func (srv *server) now() time.Time {
|
|
if srv != nil && srv.timeNow != nil {
|
|
return srv.timeNow()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func init() {
|
|
feature.HookGetSSHHostKeyPublicStrings.Set(getHostKeyPublicStrings)
|
|
ipnlocal.RegisterC2N("/ssh/usernames", handleC2NSSHUsernames)
|
|
ipnlocal.RegisterNewSSHServer(func(logf logger.Logf, lb *ipnlocal.LocalBackend) (ipnlocal.SSHServer, error) {
|
|
tsd, err := os.Executable()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
srv := &server{
|
|
lb: lb,
|
|
logf: logf,
|
|
tailscaledPath: tsd,
|
|
timeNow: func() time.Time {
|
|
return lb.ControlNow(time.Now())
|
|
},
|
|
}
|
|
|
|
return srv, nil
|
|
})
|
|
}
|
|
|
|
// attachSessionToConnIfNotShutdown ensures that srv is not shutdown before
|
|
// attaching the session to the conn. This ensures that once Shutdown is called,
|
|
// new sessions are not allowed and existing ones are cleaned up.
|
|
// It reports whether ss was attached to the conn.
|
|
func (srv *server) attachSessionToConnIfNotShutdown(ss *sshSession) bool {
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
if srv.shutdownCalled {
|
|
// Do not start any new sessions.
|
|
return false
|
|
}
|
|
ss.conn.attachSession(ss)
|
|
return true
|
|
}
|
|
|
|
// registerConn registers c unless shutdown has started, and reports whether
|
|
// c was successfully registered. The caller must initialize c's transport
|
|
// and authentication context before calling it.
|
|
func (srv *server) registerConn(c *conn) bool {
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
if srv.shutdownCalled {
|
|
return false
|
|
}
|
|
mak.Set(&srv.activeConns, c, true)
|
|
return true
|
|
}
|
|
|
|
func (srv *server) unregisterConn(c *conn) {
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
delete(srv.activeConns, c)
|
|
}
|
|
|
|
// NumActiveConns returns the number of active SSH connections.
|
|
func (srv *server) NumActiveConns() int {
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
return len(srv.activeConns)
|
|
}
|
|
|
|
// HandleSSHConn handles a Tailscale SSH connection from c.
|
|
// This is the entry point for all SSH connections.
|
|
// When this returns, the connection is closed.
|
|
func (srv *server) HandleSSHConn(nc net.Conn) error {
|
|
defer nc.Close()
|
|
metricIncomingConnections.Add(1)
|
|
c, err := srv.newConn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Capture the raw conn and the auth-phase context, and arm the grace
|
|
// timer, before the conn becomes visible in activeConns: a Shutdown
|
|
// racing the registration must find everything it needs to close the
|
|
// conn. The handshake starts after registration.
|
|
c.authRecheckDone = make(chan struct{})
|
|
c.netConn = nc
|
|
c.preAuthCtx, c.preAuthCancel = context.WithCancel(context.Background())
|
|
c.armLoginGrace()
|
|
if !srv.registerConn(c) {
|
|
c.stopConnTimers()
|
|
c.closeConn()
|
|
return errors.New("server is shutting down")
|
|
}
|
|
defer srv.unregisterConn(c)
|
|
defer c.preAuthCancel()
|
|
defer c.stopConnTimers()
|
|
// Close the process monitor when the SSH connection ends.
|
|
defer c.closeConnMonitor()
|
|
c.HandleConn(nc)
|
|
|
|
// Return nil to signal to netstack's interception that it doesn't need to
|
|
// log. If ss.HandleConn had problems, it can log itself (ideally on an
|
|
// sshSession.logf).
|
|
return nil
|
|
}
|
|
|
|
// Shutdown closes all active connections and waits for their sessions to exit.
|
|
// Closing the raw transport also interrupts authentication and cancels pending
|
|
// HoldAndDelegate requests.
|
|
func (srv *server) Shutdown() {
|
|
srv.mu.Lock()
|
|
srv.shutdownCalled = true
|
|
for c := range srv.activeConns {
|
|
c.closeConn()
|
|
}
|
|
srv.mu.Unlock()
|
|
srv.sessionWaitGroup.Wait()
|
|
}
|
|
|
|
// OnPolicyChange terminates any active connections and sessions that no
|
|
// longer match the SSH access policy.
|
|
func (srv *server) OnPolicyChange() {
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
for c := range srv.activeConns {
|
|
if !c.authCompleted.Load() {
|
|
// The connection hasn't completed authentication yet.
|
|
// In that case, the connection will be terminated when it does.
|
|
// AuthLogCallback runs a recheck on authentication success.
|
|
continue
|
|
}
|
|
go c.recheckPolicy()
|
|
}
|
|
}
|
|
|
|
// conn represents a single SSH connection and its associated
|
|
// gliderssh.Server.
|
|
//
|
|
// During the lifecycle of a connection, the following are called in order:
|
|
// Setup and discover server info
|
|
// - ServerConfigCallback
|
|
//
|
|
// Get access to a ServerPreAuthConn (useful for sending banners)
|
|
//
|
|
// Do the user auth with a NoClientAuthCallback. If user specified
|
|
// a username ending in "+password", follow this with password auth
|
|
// (to work around buggy SSH clients that don't work with noauth).
|
|
//
|
|
// Once auth is done, the conn can be multiplexed with multiple sessions and
|
|
// channels concurrently. At which point any of the following can be called
|
|
// in any order.
|
|
// - c.handleSessionPostSSHAuth
|
|
// - c.mayForwardLocalPortTo followed by a credentialed TCP dial (gliderssh on plan9)
|
|
// - c.mayForwardLocalUnixTo, the unix -L dial
|
|
// - c.mayReverseUnixForwardTo, the unix -R bind
|
|
// - c.wireForwarding's "tcpip-forward" handlers, the TCP -R bind
|
|
type conn struct {
|
|
*gliderssh.Server
|
|
srv *server
|
|
|
|
insecureSkipTailscaleAuth bool // used by tests.
|
|
|
|
// idH is the RFC4253 sec8 hash H. It is used to identify the connection,
|
|
// and is shared among all sessions. It should not be shared outside
|
|
// process. It is confusingly referred to as SessionID by the gliderlabs/ssh
|
|
// library.
|
|
idH string
|
|
connID string // ID that's shared with control
|
|
|
|
// spac is a [ssh.ServerPreAuthConn] used for sending auth banners.
|
|
// Banners cannot be sent after auth completes.
|
|
spac ssh.ServerPreAuthConn
|
|
|
|
// The following fields are set during clientAuth and are used for policy
|
|
// evaluation and session management. They are immutable after SSH
|
|
// authentication succeeds. They must not be read from other goroutines until
|
|
// authCompleted is set to true.
|
|
|
|
action0 *tailcfg.SSHAction // set by clientAuth
|
|
finalAction *tailcfg.SSHAction // set by clientAuth
|
|
|
|
info *sshConnInfo // set by setInfo (during clientAuth)
|
|
localUser *userMeta // set by clientAuth
|
|
mappedLocalUser string // policy spelling resolved to localUser at auth
|
|
userGroupIDs []string // set by clientAuth
|
|
acceptEnv []string
|
|
|
|
// authCompleted publishes the authentication state to policy rechecks.
|
|
// It is set on SSH authentication success, not on an unsigned key query
|
|
// or partial authentication success.
|
|
authCompleted atomic.Bool
|
|
|
|
// authRecheckDone is closed after the asynchronous auth-completion policy
|
|
// check. It is initialized before the connection is published.
|
|
authRecheckDone chan struct{}
|
|
|
|
// netConn is the raw connection, captured at accept in HandleSSHConn.
|
|
// It is set once, before HandleConn, and is read-only afterwards. It
|
|
// exists so conn.closeConn can tear down the conn even while the SSH
|
|
// handshake is still inside its auth callbacks, before gliderssh has
|
|
// published the conn to its Server.
|
|
netConn net.Conn
|
|
|
|
// preAuthCtx lasts from accept until conn teardown and parents the
|
|
// auth-phase HoldAndDelegate fetchSSHAction wait. preAuthCancel
|
|
// cancels it and is set once alongside netConn.
|
|
preAuthCtx context.Context
|
|
preAuthCancel context.CancelFunc
|
|
|
|
// closeOnce makes conn.closeConn idempotent.
|
|
closeOnce sync.Once
|
|
|
|
// mu protects the following fields.
|
|
//
|
|
// srv.mu should be acquired prior to mu.
|
|
// It is safe to just acquire mu, but unsafe to
|
|
// acquire mu and then srv.mu.
|
|
mu sync.Mutex // protects the following
|
|
sessions []*sshSession
|
|
|
|
// recheckMu makes recheckPolicy's policy evaluation and its
|
|
// RuleExpires timer re-arm one critical section.
|
|
recheckMu sync.Mutex
|
|
|
|
// graceTimer is the pre-auth login-grace timer. ruleTimer fires when
|
|
// the currently matched rule reaches its RuleExpires. Both are
|
|
// guarded by mu and stopped at conn teardown in HandleSSHConn.
|
|
graceTimer *time.Timer
|
|
ruleTimer *time.Timer
|
|
|
|
// teardownDone is set by stopConnTimers under mu. It prevents policy
|
|
// rechecks from arming ruleTimer after connection cleanup.
|
|
teardownDone bool
|
|
|
|
// monitor owns the process monitor for sessions and forwarding.
|
|
// It is empty on Plan 9.
|
|
monitor connMonitorState
|
|
}
|
|
|
|
func (c *conn) logf(format string, args ...any) {
|
|
format = fmt.Sprintf("%v: %v", c.connID, format)
|
|
c.srv.logf(format, args...)
|
|
}
|
|
|
|
func (c *conn) vlogf(format string, args ...any) {
|
|
if sshVerboseLogging() {
|
|
c.logf(format, args...)
|
|
}
|
|
}
|
|
|
|
// errDenied is returned by auth callbacks when a connection is denied by the
|
|
// policy. It writes the message to an auth banner and then returns an empty
|
|
// ssh.PartialSuccessError in order to stop processing authentication
|
|
// attempts and immediately disconnect the client.
|
|
func (c *conn) errDenied(message string) error {
|
|
if message == "" {
|
|
message = "tailscale: access denied"
|
|
}
|
|
if err := c.spac.SendAuthBanner(message); err != nil {
|
|
c.logf("failed to send auth banner: %s", err)
|
|
}
|
|
return errTerminal
|
|
}
|
|
|
|
// errBanner writes the given message to an auth banner and then returns an
|
|
// empty ssh.PartialSuccessError in order to stop processing authentication
|
|
// attempts and immediately disconnect the client. The contents of err is not
|
|
// leaked in the auth banner, but it is logged to the server's log.
|
|
func (c *conn) errBanner(message string, err error) error {
|
|
if err != nil {
|
|
c.logf("%s: %s", message, err)
|
|
}
|
|
if err := c.spac.SendAuthBanner("tailscale: " + message + "\n"); err != nil {
|
|
c.logf("failed to send auth banner: %s", err)
|
|
}
|
|
return errTerminal
|
|
}
|
|
|
|
// errUnexpected is returned by auth callbacks that encounter an unexpected
|
|
// error, such as being unable to send an auth banner. It sends an empty
|
|
// ssh.PartialSuccessError to tell ssh.Server to stop processing
|
|
// authentication attempts and instead disconnect immediately.
|
|
func (c *conn) errUnexpected(err error) error {
|
|
c.logf("terminal error: %s", err)
|
|
return errTerminal
|
|
}
|
|
|
|
// clientAuth is responsible for performing client authentication.
|
|
//
|
|
// If policy evaluation fails, it returns an error.
|
|
// If access is denied, it returns an error. This must always be an empty
|
|
// ssh.PartialSuccessError to prevent further authentication methods from
|
|
// being tried.
|
|
func (c *conn) clientAuth(cm ssh.ConnMetadata) (perms *ssh.Permissions, retErr error) {
|
|
defer func() {
|
|
if pse, ok := retErr.(*ssh.PartialSuccessError); ok {
|
|
if pse.Next.GSSAPIWithMICConfig != nil ||
|
|
pse.Next.KeyboardInteractiveCallback != nil ||
|
|
pse.Next.PasswordCallback != nil ||
|
|
pse.Next.PublicKeyCallback != nil {
|
|
panic("clientAuth attempted to return a non-empty PartialSuccessError")
|
|
}
|
|
} else if retErr != nil {
|
|
panic(fmt.Sprintf("clientAuth attempted to return a non-PartialSuccessError error of type: %t", retErr))
|
|
}
|
|
}()
|
|
|
|
if c.insecureSkipTailscaleAuth {
|
|
return &ssh.Permissions{}, nil
|
|
}
|
|
|
|
if err := c.setInfo(cm); err != nil {
|
|
return nil, c.errBanner("failed to get connection info", err)
|
|
}
|
|
|
|
action, localUser, acceptEnv, _, result := c.evaluatePolicy()
|
|
switch result {
|
|
case accepted:
|
|
// do nothing
|
|
case rejectedUser:
|
|
// The banner names the requested user: that name is the client's
|
|
// own request and reflects only tailnet policy (the client's own
|
|
// ACL view), never a local-host fact, so naming it leaks nothing.
|
|
// The local-user lookup paths below must not vary with the failure
|
|
// cause, so no banner is a username-existence oracle; the attempted
|
|
// user is logged server-side (matchRule's vlogf).
|
|
return nil, c.errBanner(fmt.Sprintf("tailnet policy does not permit you to SSH as user %q", c.info.sshUser), nil)
|
|
case rejected, noPolicy:
|
|
return nil, c.errBanner("tailnet policy does not permit you to SSH to this node", fmt.Errorf("failed to evaluate policy, result: %s", result))
|
|
default:
|
|
return nil, c.errBanner("failed to evaluate tailnet policy", fmt.Errorf("failed to evaluate policy, result: %s", result))
|
|
}
|
|
|
|
c.action0 = action
|
|
|
|
if action.Accept || action.HoldAndDelegate != "" {
|
|
// Immediately look up user information for purposes of generating
|
|
// hold and delegate URL (if necessary).
|
|
lu, err := userLookup(localUser)
|
|
if err != nil {
|
|
// One fixed client-visible message for every lookup failure
|
|
// cause; the underlying error is logged server-side only, so
|
|
// the banner reveals nothing about local users beyond the name
|
|
// the policy already mapped for this client.
|
|
return nil, c.errBanner(fmt.Sprintf("failed to look up local user %q ", localUser), err)
|
|
}
|
|
gids, err := lu.GroupIds()
|
|
if err != nil {
|
|
return nil, c.errBanner("failed to look up local user's group IDs", err)
|
|
}
|
|
c.userGroupIDs = gids
|
|
c.localUser = lu
|
|
c.mappedLocalUser = localUser
|
|
c.acceptEnv = acceptEnv
|
|
}
|
|
|
|
for {
|
|
switch {
|
|
case action.Accept:
|
|
metricTerminalAccept.Add(1)
|
|
if action.Message != "" {
|
|
if err := c.spac.SendAuthBanner(action.Message); err != nil {
|
|
return nil, c.errUnexpected(fmt.Errorf("error sending auth welcome message: %w", err))
|
|
}
|
|
}
|
|
c.finalAction = action
|
|
return &ssh.Permissions{}, nil
|
|
case action.Reject:
|
|
metricTerminalReject.Add(1)
|
|
c.finalAction = action
|
|
return nil, c.errDenied(action.Message)
|
|
case action.HoldAndDelegate != "":
|
|
if action.Message != "" {
|
|
if err := c.spac.SendAuthBanner(action.Message); err != nil {
|
|
return nil, c.errUnexpected(fmt.Errorf("error sending hold and delegate message: %w", err))
|
|
}
|
|
}
|
|
|
|
url := action.HoldAndDelegate
|
|
|
|
// Allow the delegate fetch to reach its own timeout before
|
|
// login grace closes the transport. Keep the transport deadline
|
|
// in case the fetch does not return on context cancellation.
|
|
c.extendLoginGrace(holdDelegateGrace)
|
|
|
|
// Connection teardown also cancels the delegate fetch.
|
|
ctx := c.preAuthCtx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
metricHolds.Add(1)
|
|
url = c.expandDelegateURLLocked(url)
|
|
|
|
var err error
|
|
action, err = c.fetchSSHAction(ctx, url)
|
|
if err != nil {
|
|
metricTerminalFetchError.Add(1)
|
|
return nil, c.errBanner("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err))
|
|
}
|
|
default:
|
|
metricTerminalMalformed.Add(1)
|
|
return nil, c.errBanner("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ServerConfig implements gliderssh.ServerConfigCallback.
|
|
func (c *conn) ServerConfig(ctx gliderssh.Context) *ssh.ServerConfig {
|
|
return &ssh.ServerConfig{
|
|
AuthLogCallback: func(_ ssh.ConnMetadata, _ string, err error) {
|
|
// x/crypto calls this with nil only after successful authentication.
|
|
// Unsigned public-key queries do not reach this callback.
|
|
if err == nil && !c.insecureSkipTailscaleAuth {
|
|
c.mu.Lock()
|
|
c.authCompleted.Store(true)
|
|
c.stopLoginGraceLocked()
|
|
c.mu.Unlock()
|
|
// Cover policy changes during authentication and arm rule expiry.
|
|
go func() {
|
|
c.recheckPolicy()
|
|
if c.authRecheckDone != nil {
|
|
close(c.authRecheckDone)
|
|
}
|
|
}()
|
|
}
|
|
},
|
|
PreAuthConnCallback: func(spac ssh.ServerPreAuthConn) {
|
|
c.spac = spac
|
|
},
|
|
NoClientAuth: true, // required for the NoClientAuthCallback to run
|
|
NoClientAuthCallback: func(cm ssh.ConnMetadata) (*ssh.Permissions, error) {
|
|
// First perform client authentication, which can potentially
|
|
// involve multiple steps (for example prompting user to log in to
|
|
// Tailscale admin panel to confirm identity).
|
|
perms, err := c.clientAuth(cm)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Authentication succeeded. Buggy SSH clients get confused by
|
|
// success from the "none" auth method. As a workaround, let users
|
|
// specify a username ending in "+password" to force password auth.
|
|
// The actual value of the password doesn't matter.
|
|
if strings.HasSuffix(cm.User(), forcePasswordSuffix) {
|
|
return nil, &ssh.PartialSuccessError{
|
|
Next: ssh.ServerAuthCallbacks{
|
|
PasswordCallback: func(_ ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
|
return &ssh.Permissions{}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return perms, nil
|
|
},
|
|
PasswordCallback: func(cm ssh.ConnMetadata, pword []byte) (*ssh.Permissions, error) {
|
|
// Some clients don't request 'none' authentication. Instead, they
|
|
// immediately supply a password. We humor them by accepting the
|
|
// password, but authenticate as usual, ignoring the actual value of
|
|
// the password.
|
|
return c.clientAuth(cm)
|
|
},
|
|
PublicKeyCallback: func(cm ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
|
// Some clients don't request 'none' authentication. Instead, they
|
|
// immediately supply a public key. We humor them by accepting the
|
|
// key, but authenticate as usual, ignoring the actual content of
|
|
// the key.
|
|
return c.clientAuth(cm)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (srv *server) newConn() (*conn, error) {
|
|
srv.mu.Lock()
|
|
if srv.shutdownCalled {
|
|
srv.mu.Unlock()
|
|
// Avoid constructing a connection after shutdown. registerConn
|
|
// checks again before publishing the initialized connection.
|
|
return nil, errors.New("server is shutting down")
|
|
}
|
|
srv.mu.Unlock()
|
|
c := &conn{srv: srv}
|
|
now := srv.now()
|
|
c.connID = fmt.Sprintf("ssh-conn-%s-%02x", now.UTC().Format(tstime.BasicDateTTime), randBytes(5))
|
|
streamLocalFwdHandler := &gliderssh.ForwardedUnixHandler{}
|
|
c.Server = &gliderssh.Server{
|
|
Version: "Tailscale",
|
|
ServerConfigCallback: c.ServerConfig,
|
|
|
|
IdleTimeout: sshIdleTimeout(),
|
|
|
|
Handler: c.handleSessionPostSSHAuth,
|
|
LocalPortForwardingCallback: c.mayForwardLocalPortTo,
|
|
ReversePortForwardingCallback: c.mayReversePortForwardTo,
|
|
|
|
LocalUnixForwardingCallback: c.mayForwardLocalUnixTo,
|
|
ReverseUnixForwardingCallback: c.mayReverseUnixForwardTo,
|
|
|
|
SubsystemHandlers: map[string]gliderssh.SubsystemHandler{
|
|
"sftp": c.handleSessionPostSSHAuth,
|
|
},
|
|
ChannelHandlers: map[string]gliderssh.ChannelHandler{
|
|
"direct-tcpip": gliderssh.DirectTCPIPHandler,
|
|
"direct-streamlocal@openssh.com": gliderssh.DirectStreamLocalHandler,
|
|
},
|
|
RequestHandlers: map[string]gliderssh.RequestHandler{
|
|
"streamlocal-forward@openssh.com": streamLocalFwdHandler.HandleSSHRequest,
|
|
"cancel-streamlocal-forward@openssh.com": streamLocalFwdHandler.HandleSSHRequest,
|
|
},
|
|
}
|
|
ss := c.Server
|
|
maps.Copy(ss.RequestHandlers, gliderssh.DefaultRequestHandlers)
|
|
maps.Copy(ss.ChannelHandlers, gliderssh.DefaultChannelHandlers)
|
|
maps.Copy(ss.SubsystemHandlers, gliderssh.DefaultSubsystemHandlers)
|
|
c.wireForwarding(ss)
|
|
keys, err := getHostKeys(srv.lb.TailscaleVarRoot(), srv.logf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, signer := range keys {
|
|
ss.AddHostKey(signer)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// mayReversePortPortForwardTo reports whether the ctx should be allowed to port forward
|
|
// to the specified host and port.
|
|
// TODO(bradfitz/maisem): should we have more checks on host/port?
|
|
func (c *conn) mayReversePortForwardTo(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool {
|
|
if sshDisableForwarding() || c.finalAction == nil || !c.finalAction.AllowRemotePortForwarding {
|
|
return false
|
|
}
|
|
// The latched forwarding bit may be stale. See recheckPolicy.
|
|
if !c.recheckForwardingPolicy() {
|
|
return false
|
|
}
|
|
metricRemotePortForward.Add(1)
|
|
return true
|
|
}
|
|
|
|
// mayForwardLocalPortTo reports whether the ctx should be allowed to port forward
|
|
// to the specified host and port.
|
|
// TODO(bradfitz/maisem): should we have more checks on host/port?
|
|
func (c *conn) mayForwardLocalPortTo(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool {
|
|
if sshDisableForwarding() || c.finalAction == nil || !c.finalAction.AllowLocalPortForwarding {
|
|
return false
|
|
}
|
|
// The latched forwarding bit may be stale. See recheckPolicy.
|
|
if !c.recheckForwardingPolicy() {
|
|
return false
|
|
}
|
|
metricLocalPortForward.Add(1)
|
|
return true
|
|
}
|
|
|
|
// mayForwardLocalUnixTo is the server-side handler for
|
|
// direct-streamlocal@openssh.com (SSH -L with Unix sockets). It returns a
|
|
// connection to the specified Unix domain socket path if forwarding is
|
|
// permitted, or an error if not. On unix the dial goes through
|
|
// conn.fwdDialUnix.
|
|
func (c *conn) mayForwardLocalUnixTo(ctx gliderssh.Context, socketPath string) (net.Conn, error) {
|
|
if sshDisableForwarding() || c.finalAction == nil || !c.finalAction.AllowLocalPortForwarding {
|
|
return nil, gliderssh.ErrRejected
|
|
}
|
|
// The latched forwarding bit may be stale. See recheckPolicy.
|
|
if !c.recheckForwardingPolicy() {
|
|
return nil, gliderssh.ErrRejected
|
|
}
|
|
metricLocalPortForward.Add(1)
|
|
return c.fwdDialUnix(ctx, socketPath)
|
|
}
|
|
|
|
// mayReverseUnixForwardTo is the server-side handler for
|
|
// streamlocal-forward@openssh.com (SSH -R with Unix sockets). It returns a
|
|
// listener for the specified Unix domain socket path if reverse forwarding
|
|
// is permitted, or an error if not. On unix the listener comes from
|
|
// conn.fwdListenUnix.
|
|
func (c *conn) mayReverseUnixForwardTo(ctx gliderssh.Context, socketPath string) (net.Listener, error) {
|
|
if sshDisableForwarding() || c.finalAction == nil || !c.finalAction.AllowRemotePortForwarding {
|
|
return nil, gliderssh.ErrRejected
|
|
}
|
|
// The latched forwarding bit may be stale. See recheckPolicy.
|
|
if !c.recheckForwardingPolicy() {
|
|
return nil, gliderssh.ErrRejected
|
|
}
|
|
metricRemotePortForward.Add(1)
|
|
return c.fwdListenUnix(ctx, socketPath)
|
|
}
|
|
|
|
// sshPolicy returns the SSHPolicy for current node.
|
|
// If there is no SSHPolicy in the netmap, it returns a debugPolicy
|
|
// if one is defined.
|
|
func (c *conn) sshPolicy() (_ *tailcfg.SSHPolicy, ok bool) {
|
|
lb := c.srv.lb
|
|
if !lb.ShouldRunSSH() {
|
|
return nil, false
|
|
}
|
|
nm := lb.NetMapNoPeers()
|
|
if nm == nil {
|
|
return nil, false
|
|
}
|
|
if pol := nm.SSHPolicy; pol != nil && !envknob.SSHIgnoreTailnetPolicy() {
|
|
return pol, true
|
|
}
|
|
debugPolicyFile := envknob.SSHPolicyFile()
|
|
if debugPolicyFile != "" {
|
|
c.logf("reading debug SSH policy file: %v", debugPolicyFile)
|
|
f, err := os.ReadFile(debugPolicyFile)
|
|
if err != nil {
|
|
c.logf("error reading debug SSH policy file: %v", err)
|
|
return nil, false
|
|
}
|
|
p := new(tailcfg.SSHPolicy)
|
|
if err := json.Unmarshal(f, p); err != nil {
|
|
c.logf("invalid JSON in %v: %v", debugPolicyFile, err)
|
|
return nil, false
|
|
}
|
|
return p, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func toIPPort(a net.Addr) (ipp netip.AddrPort) {
|
|
ta, ok := a.(*net.TCPAddr)
|
|
if !ok {
|
|
return
|
|
}
|
|
tanetaddr, ok := netip.AddrFromSlice(ta.IP)
|
|
if !ok {
|
|
return
|
|
}
|
|
return netip.AddrPortFrom(tanetaddr.Unmap(), uint16(ta.Port))
|
|
}
|
|
|
|
// connInfo populates the sshConnInfo from the provided arguments,
|
|
// validating only that they represent a known Tailscale identity.
|
|
func (c *conn) setInfo(cm ssh.ConnMetadata) error {
|
|
if c.info != nil {
|
|
return nil
|
|
}
|
|
ci := &sshConnInfo{
|
|
sshUser: strings.TrimSuffix(cm.User(), forcePasswordSuffix),
|
|
src: toIPPort(cm.RemoteAddr()),
|
|
dst: toIPPort(cm.LocalAddr()),
|
|
}
|
|
if !tsaddr.IsTailscaleIP(ci.dst.Addr()) {
|
|
return fmt.Errorf("tailssh: rejecting non-Tailscale local address %v", ci.dst)
|
|
}
|
|
if !tsaddr.IsTailscaleIP(ci.src.Addr()) {
|
|
return fmt.Errorf("tailssh: rejecting non-Tailscale remote address %v", ci.src)
|
|
}
|
|
node, uprof, ok := c.srv.lb.WhoIs("tcp", ci.src)
|
|
if !ok {
|
|
return fmt.Errorf("unknown Tailscale identity from src %v", ci.src)
|
|
}
|
|
ci.node = node
|
|
ci.uprof = uprof
|
|
|
|
c.idH = string(cm.SessionID())
|
|
c.info = ci
|
|
c.logf("handling conn: %v", ci.String())
|
|
return nil
|
|
}
|
|
|
|
type evalResult string
|
|
|
|
const (
|
|
noPolicy evalResult = "no policy"
|
|
rejected evalResult = "rejected"
|
|
rejectedUser evalResult = "rejected user"
|
|
accepted evalResult = "accept"
|
|
)
|
|
|
|
// evaluatePolicy returns the SSHAction, localUser, acceptEnv, and matched
|
|
// rule after evaluating the SSHPolicy for this conn. The rule is the
|
|
// netmap rule that produced the action, nil unless result is accepted.
|
|
// Callers use its RuleExpires to arm the conn-level expiry timer.
|
|
func (c *conn) evaluatePolicy() (*tailcfg.SSHAction, string, []string, *tailcfg.SSHRule, evalResult) {
|
|
return c.evaluatePolicyWithUserLookup(userLookup)
|
|
}
|
|
|
|
func (c *conn) evaluatePolicyWithUserLookup(lookup func(string) (*userMeta, error)) (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, rule *tailcfg.SSHRule, result evalResult) {
|
|
pol, ok := c.sshPolicy()
|
|
if !ok {
|
|
return nil, "", nil, nil, noPolicy
|
|
}
|
|
return c.evalSSHPolicyWithUserLookup(pol, lookup)
|
|
}
|
|
|
|
// handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication,
|
|
// but not necessarily before all the Tailscale-level extra verification has
|
|
// completed. It also handles SFTP requests.
|
|
func (c *conn) handleSessionPostSSHAuth(s gliderssh.Session) {
|
|
// Re-evaluate the policy before honoring a new session channel. The
|
|
// conn's latched finalAction, covering localUser, recorders, duration,
|
|
// and the env allowlist, may be stale if the policy changed since
|
|
// auth. A material change closes the connection, and the session
|
|
// exits 255 before running anything.
|
|
if material, why, _ := c.materialPolicyChange(); material {
|
|
fmt.Fprintf(s.Stderr(), "tailscale: access revoked\r\n")
|
|
// 255 is an SSH-layer failure. No user command ever ran. See
|
|
// run()'s attachSession branch, which uses the same exit code.
|
|
s.Exit(255)
|
|
c.revokeConn(why)
|
|
return
|
|
}
|
|
|
|
// Do this check after auth, but before starting the session.
|
|
switch s.Subsystem() {
|
|
case "sftp":
|
|
if sshDisableSFTP() {
|
|
fmt.Fprintf(s.Stderr(), "sftp disabled\r\n")
|
|
s.Exit(1)
|
|
return
|
|
}
|
|
metricSFTP.Add(1)
|
|
case "":
|
|
// Regular SSH session.
|
|
default:
|
|
fmt.Fprintf(s.Stderr(), "Unsupported subsystem %q\r\n", s.Subsystem())
|
|
s.Exit(1)
|
|
return
|
|
}
|
|
|
|
ss := c.newSSHSession(s)
|
|
ss.logf("handling new SSH connection from %v (%v) to ssh-user %q", c.info.uprof.LoginName, c.info.src.Addr(), c.localUser.Username)
|
|
ss.logf("access granted to %v as ssh-user %q", c.info.uprof.LoginName, c.localUser.Username)
|
|
|
|
if f, ok := hookSSHLoginSuccess.GetOk(); ok {
|
|
f(c.srv.logf, c)
|
|
}
|
|
|
|
ss.run()
|
|
}
|
|
|
|
func (c *conn) expandDelegateURLLocked(actionURL string) string {
|
|
nm := c.srv.lb.NetMapNoPeers()
|
|
ci := c.info
|
|
lu := c.localUser
|
|
var dstNodeID string
|
|
if nm != nil {
|
|
dstNodeID = fmt.Sprint(int64(nm.SelfNode.ID()))
|
|
}
|
|
return strings.NewReplacer(
|
|
"$SRC_NODE_IP", url.QueryEscape(ci.src.Addr().String()),
|
|
"$SRC_NODE_ID", fmt.Sprint(int64(ci.node.ID())),
|
|
"$DST_NODE_IP", url.QueryEscape(ci.dst.Addr().String()),
|
|
"$DST_NODE_ID", dstNodeID,
|
|
"$SSH_USER", url.QueryEscape(ci.sshUser),
|
|
"$LOCAL_USER", url.QueryEscape(lu.Username),
|
|
).Replace(actionURL)
|
|
}
|
|
|
|
// sshSession is an accepted Tailscale SSH session.
|
|
type sshSession struct {
|
|
gliderssh.Session
|
|
sharedID string // ID that's shared with control
|
|
logf logger.Logf
|
|
|
|
ctx context.Context
|
|
cancelCtx context.CancelCauseFunc
|
|
conn *conn
|
|
agentListener net.Listener // non-nil if agent-forwarding requested+allowed
|
|
|
|
// initialized by launchProcess:
|
|
osSessionState // the process itself; see process_*.go
|
|
wrStdin io.WriteCloser
|
|
rdStdout io.ReadCloser
|
|
rdStderr io.ReadCloser // rdStderr is nil for pty sessions
|
|
ptyReq *gliderssh.Pty // non-nil for pty sessions
|
|
|
|
// childPipes is a list of pipes that need to be closed when the process exits.
|
|
// For pty sessions, this is the tty fd.
|
|
// For non-pty sessions, this is the stdin, stdout, stderr fds.
|
|
childPipes []io.Closer
|
|
|
|
// We use this sync.Once to ensure that we only terminate the process once,
|
|
// either it exits itself or is terminated
|
|
exitOnce sync.Once
|
|
|
|
// exitHandled is closed when killProcessOnContextDone finishes writing any
|
|
// termination message to the client. run() waits on this before calling
|
|
// ss.Exit to ensure the message is flushed before the SSH channel is torn
|
|
// down. It is initialized by run() before starting killProcessOnContextDone.
|
|
exitHandled chan struct{}
|
|
}
|
|
|
|
func (ss *sshSession) vlogf(format string, args ...any) {
|
|
if sshVerboseLogging() {
|
|
ss.logf(format, args...)
|
|
}
|
|
}
|
|
|
|
func (c *conn) newSSHSession(s gliderssh.Session) *sshSession {
|
|
sharedID := fmt.Sprintf("sess-%s-%02x", c.srv.now().UTC().Format(tstime.BasicDateTTime), randBytes(5))
|
|
c.logf("starting session: %v", sharedID)
|
|
ctx, cancel := context.WithCancelCause(s.Context())
|
|
return &sshSession{
|
|
Session: s,
|
|
sharedID: sharedID,
|
|
ctx: ctx,
|
|
cancelCtx: cancel,
|
|
conn: c,
|
|
logf: logger.WithPrefix(c.srv.logf, "ssh-session("+sharedID+"): "),
|
|
}
|
|
}
|
|
|
|
const (
|
|
// delegateFetchTimeout bounds one delegated action fetch, including retries.
|
|
delegateFetchTimeout = 30 * time.Minute
|
|
|
|
// holdDelegateGrace is the login-grace period while fetching a delegated
|
|
// action. It allows the fetch to time out before closing the transport.
|
|
holdDelegateGrace = delegateFetchTimeout + 5*time.Minute
|
|
)
|
|
|
|
// armLoginGrace arms the pre-auth login-grace timer; on expiry the
|
|
// connection is closed at the raw socket. The timer is stopped at auth
|
|
// completion by stopLoginGraceLocked in AuthLogCallback,
|
|
// and extended while a HoldAndDelegate approval is in flight by
|
|
// extendLoginGrace. Superseded timers are disarmed by the self-identity
|
|
// check under mu.
|
|
func (c *conn) armLoginGrace() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.armLoginGraceLocked(loginGraceTime())
|
|
}
|
|
|
|
func (c *conn) armLoginGraceLocked(d time.Duration) {
|
|
var t *time.Timer
|
|
t = time.AfterFunc(d, func() {
|
|
c.mu.Lock()
|
|
if c.authCompleted.Load() || c.graceTimer != t {
|
|
// Auth already completed, or this timer was stopped or
|
|
// superseded by a later re-arm.
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
c.graceTimer = nil
|
|
c.mu.Unlock()
|
|
c.logf("ssh: login grace period expired before authentication completed; closing connection")
|
|
c.closeConn()
|
|
})
|
|
c.graceTimer = t
|
|
}
|
|
|
|
// stopLoginGraceLocked stops the login-grace timer once auth has
|
|
// completed. The caller holds c.mu.
|
|
func (c *conn) stopLoginGraceLocked() {
|
|
if c.graceTimer != nil {
|
|
c.graceTimer.Stop()
|
|
c.graceTimer = nil
|
|
}
|
|
}
|
|
|
|
// extendLoginGrace re-arms the login-grace timer to d from now.
|
|
func (c *conn) extendLoginGrace(d time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.stopLoginGraceLocked()
|
|
c.armLoginGraceLocked(d)
|
|
}
|
|
|
|
// armRuleExpiryLocked arms the conn-level RuleExpires timer, which fires
|
|
// at expiry and re-evaluates the policy; an expired rule fails closed in
|
|
// matchRule. The timer is armed at auth completion and re-armed on every
|
|
// non-material re-evaluation by recheckPolicy. The caller holds c.mu.
|
|
func (c *conn) armRuleExpiryLocked(r *tailcfg.SSHRule) {
|
|
if c.ruleTimer != nil {
|
|
// Re-arming happens when recheckPolicy's non-material
|
|
// re-evaluation matched a differently-expiring rule. A superseded
|
|
// one-shot timer is stopped rather than left to fire its own
|
|
// extra recheck later.
|
|
c.ruleTimer.Stop()
|
|
c.ruleTimer = nil
|
|
}
|
|
if r == nil || r.RuleExpires == nil {
|
|
return
|
|
}
|
|
d := r.RuleExpires.Sub(c.srv.now())
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
c.ruleTimer = time.AfterFunc(d, func() { go c.recheckPolicy() })
|
|
}
|
|
|
|
// stopConnTimers stops the login-grace and RuleExpires timers at conn
|
|
// teardown. It also latches teardownDone, so any later non-material
|
|
// recheck must not re-arm ruleTimer. See the field's comment.
|
|
func (c *conn) stopConnTimers() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.teardownDone = true
|
|
if c.graceTimer != nil {
|
|
c.graceTimer.Stop()
|
|
c.graceTimer = nil
|
|
}
|
|
if c.ruleTimer != nil {
|
|
c.ruleTimer.Stop()
|
|
c.ruleTimer = nil
|
|
}
|
|
}
|
|
|
|
// closeConn cancels authentication and closes both the raw transport and
|
|
// gliderssh connections. It is safe to call concurrently and more than once.
|
|
func (c *conn) closeConn() {
|
|
c.closeOnce.Do(func() {
|
|
if c.preAuthCancel != nil {
|
|
c.preAuthCancel()
|
|
}
|
|
if c.netConn != nil {
|
|
c.netConn.Close()
|
|
}
|
|
if c.Server != nil {
|
|
c.Server.Close()
|
|
}
|
|
})
|
|
}
|
|
|
|
// revokeConn cancels each session with an "Access revoked." message and
|
|
// closes the connection. This is triggered when a policy change invalidates
|
|
// its grant.
|
|
func (c *conn) revokeConn(reason string) {
|
|
metricPolicyChangeKick.Add(1)
|
|
c.logf("session no longer valid per new SSH policy (%v); closing connection", reason)
|
|
c.mu.Lock()
|
|
for _, s := range c.sessions {
|
|
s.cancelCtx(userVisibleError{
|
|
fmt.Sprintf("Access revoked.\r\n"),
|
|
context.Canceled,
|
|
})
|
|
}
|
|
c.mu.Unlock()
|
|
c.closeConn()
|
|
}
|
|
|
|
// recheckPolicy re-evaluates the current SSHPolicy against what the conn
|
|
// latched at auth time and reports whether the conn still holds its grant.
|
|
// On a material change, either revocation or narrowing, it revokes the
|
|
// connection and returns false; see materialPolicyChange. It costs a
|
|
// local netmap policy scan plus, only when the mapped username is
|
|
// unchanged, a userLookup to verify account identity.
|
|
func (c *conn) recheckPolicy() bool {
|
|
return c.recheckPolicyWithUserLookup(userLookup)
|
|
}
|
|
|
|
// recheckForwardingPolicy checks policy without forking an account lookup for
|
|
// each forwarded connection (e.g. every request through SSH -D). Account
|
|
// identity is checked at authentication, policy updates, rule expiry, and
|
|
// session gates.
|
|
func (c *conn) recheckForwardingPolicy() bool {
|
|
return c.recheckPolicyWithUserLookup(func(string) (*userMeta, error) {
|
|
return c.localUser, nil
|
|
})
|
|
}
|
|
|
|
func (c *conn) recheckPolicyWithUserLookup(lookup func(string) (*userMeta, error)) bool {
|
|
c.recheckMu.Lock()
|
|
defer c.recheckMu.Unlock()
|
|
material, why, rule := c.materialPolicyChangeWithUserLookup(lookup)
|
|
if !material {
|
|
// Keep the conn-level RuleExpires timer in step with the rule
|
|
// that currently matches: a non-material re-evaluation, from a
|
|
// policy edit or kick, can land on a different rule whose expiry
|
|
// is later, earlier, or absent. Do not re-arm past the conn's
|
|
// teardown; see teardownDone.
|
|
c.mu.Lock()
|
|
if !c.teardownDone {
|
|
c.armRuleExpiryLocked(rule)
|
|
}
|
|
c.mu.Unlock()
|
|
return true
|
|
}
|
|
c.revokeConn(why)
|
|
return false
|
|
}
|
|
|
|
// materialPolicyChange reports whether the current policy revokes or narrows
|
|
// the authenticated grant, or changes its recording requirements.
|
|
func (c *conn) materialPolicyChange() (material bool, why string, rule *tailcfg.SSHRule) {
|
|
return c.materialPolicyChangeWithUserLookup(userLookup)
|
|
}
|
|
|
|
// materialPolicyChangeWithUserLookup re-evaluates the grant using lookup for
|
|
// account identity, including the non-root check for "=" mappings.
|
|
func (c *conn) materialPolicyChangeWithUserLookup(lookup func(string) (*userMeta, error)) (material bool, why string, rule *tailcfg.SSHRule) {
|
|
// Resolve each name only once, including the lookup required by "=".
|
|
// An inconclusive lookup may retain the authenticated identity only for
|
|
// the same policy spelling; it cannot authorize a remapping or root.
|
|
type lookupResult struct {
|
|
u *userMeta
|
|
err error
|
|
}
|
|
lookups := map[string]lookupResult{}
|
|
cachedLookup := func(name string) (*userMeta, error) {
|
|
r, ok := lookups[name]
|
|
if !ok {
|
|
r.u, r.err = lookup(name)
|
|
lookups[name] = r
|
|
}
|
|
return r.u, r.err
|
|
}
|
|
a, localUser, freshAcceptEnv, rule, result := c.evaluatePolicyWithUserLookup(func(name string) (*userMeta, error) {
|
|
u, err := cachedLookup(name)
|
|
if err != nil && !isUserMissing(err) && name == c.mappedLocalUser {
|
|
return c.localUser, nil
|
|
}
|
|
return u, err
|
|
})
|
|
c.vlogf("recheck: %+v %v %v", a, localUser, result)
|
|
if result != accepted {
|
|
return true, fmt.Sprintf("policy evaluation result: %v", result), nil
|
|
}
|
|
if a == nil {
|
|
return true, "policy returned a nil action", nil
|
|
}
|
|
if a.Reject {
|
|
return true, "rule changed from Accept to Reject", nil
|
|
}
|
|
if a.Accept && a.HoldAndDelegate != "" {
|
|
return true, "malformed action: both Accept and HoldAndDelegate", nil
|
|
}
|
|
if !a.Accept && a.HoldAndDelegate == "" {
|
|
return true, "malformed action: neither Accept, Reject, nor HoldAndDelegate", nil
|
|
}
|
|
// Compare the policy spelling before lookup: aliases and numeric UIDs
|
|
// can resolve to a different canonical username without a policy change.
|
|
if localUser != c.mappedLocalUser {
|
|
return true, fmt.Sprintf("local user changed from %q to %q", c.mappedLocalUser, localUser), nil
|
|
}
|
|
// Re-resolve the account to detect deletion or UID/primary GID changes. A lookup
|
|
// outage leaves identity unchanged, but must not skip policy checks.
|
|
lu, err := cachedLookup(localUser)
|
|
if err != nil {
|
|
if isUserMissing(err) {
|
|
return true, fmt.Sprintf("local user %q no longer exists", localUser), nil
|
|
}
|
|
c.logf("ssh: policy recheck: local user %q lookup failed (%v); retaining identity and checking remaining policy", localUser, err)
|
|
} else if lu.Uid != c.localUser.Uid || lu.Gid != c.localUser.Gid || lu.Username != c.localUser.Username {
|
|
return true, fmt.Sprintf("local user %q identity changed from %s (uid %s, gid %s) to %s (uid %s, gid %s)", localUser, c.localUser.Username, c.localUser.Uid, c.localUser.Gid, lu.Username, lu.Uid, lu.Gid), nil
|
|
}
|
|
old := c.finalAction
|
|
if old == nil || c.action0 == nil {
|
|
return true, "conn has no latched action", nil
|
|
}
|
|
if acceptEnvNarrowed(c.acceptEnv, freshAcceptEnv) {
|
|
return true, "accept-env pattern removed", nil
|
|
}
|
|
oldRecorders, oldFailure := recordingConfig(c.action0, old)
|
|
freshRecorders, freshFailure := recordingConfig(a, a)
|
|
if !a.Accept {
|
|
if c.action0.Accept {
|
|
return true, "rule changed from Accept to HoldAndDelegate", nil
|
|
}
|
|
// Keep the delegated grant, but compare any recording requirements
|
|
// inherited from the hold rule as well as its AcceptEnv above.
|
|
freshRecorders, freshFailure = recordingConfig(a, old)
|
|
}
|
|
if recordersChanged(oldRecorders, freshRecorders) {
|
|
return true, "session recording obligation changed", nil
|
|
}
|
|
if recFailureChanged(oldFailure, freshFailure) {
|
|
return true, "recording-failure policy changed", nil
|
|
}
|
|
if !a.Accept {
|
|
return false, "", rule
|
|
}
|
|
if old.AllowLocalPortForwarding && !a.AllowLocalPortForwarding {
|
|
return true, "local port forwarding revoked", nil
|
|
}
|
|
if old.AllowRemotePortForwarding && !a.AllowRemotePortForwarding {
|
|
return true, "remote port forwarding revoked", nil
|
|
}
|
|
if old.AllowAgentForwarding && !a.AllowAgentForwarding {
|
|
return true, "agent forwarding revoked", nil
|
|
}
|
|
if durShorter(a.SessionDuration, old.SessionDuration) {
|
|
return true, fmt.Sprintf("session duration cap shortened from %v to %v", old.SessionDuration, a.SessionDuration), nil
|
|
}
|
|
return false, "", rule
|
|
}
|
|
|
|
// recordersChanged compares recorder lists without regard to order, but with
|
|
// duplicate counts preserved. Changes in either direction require
|
|
// reauthentication because recording state is fixed at authentication.
|
|
func recordersChanged(old, fresh []netip.AddrPort) bool {
|
|
if len(old) != len(fresh) {
|
|
return true
|
|
}
|
|
counts := make(map[netip.AddrPort]int, len(old))
|
|
for _, r := range old {
|
|
counts[r]++
|
|
}
|
|
for _, r := range fresh {
|
|
if counts[r] == 0 {
|
|
return true
|
|
}
|
|
counts[r]--
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isUserMissing recognizes an absent account, not an unavailable lookup source.
|
|
// NSS can report "not found" (getent exit 2) during a directory outage;
|
|
// userLookup cannot distinguish that case from deletion.
|
|
func isUserMissing(err error) bool {
|
|
_, nameMissing := errors.AsType[stduser.UnknownUserError](err)
|
|
_, uidMissing := errors.AsType[stduser.UnknownUserIdError](err)
|
|
return nameMissing || uidMissing
|
|
}
|
|
|
|
// recFailureChanged compares failure requirements in both directions, including
|
|
// messages and notification destinations. Nil is equivalent to an empty action.
|
|
func recFailureChanged(old, fresh *tailcfg.SSHRecorderFailureAction) bool {
|
|
var o, f tailcfg.SSHRecorderFailureAction
|
|
if old != nil {
|
|
o = *old
|
|
}
|
|
if fresh != nil {
|
|
f = *fresh
|
|
}
|
|
return o != f
|
|
}
|
|
|
|
// durShorter reports whether fresh is a shorter cap than old. Zero means
|
|
// uncapped, the maximum. A longer or removed cap is an extension.
|
|
func durShorter(fresh, old time.Duration) bool {
|
|
if fresh == 0 {
|
|
return false
|
|
}
|
|
if old == 0 {
|
|
return true
|
|
}
|
|
return fresh < old
|
|
}
|
|
|
|
// acceptEnvNarrowed reports whether the fresh accept-env patterns
|
|
// accept strictly less than the latched ones. Literal latched patterns
|
|
// are checked against the fresh patterns with matchAcceptEnvPattern, so
|
|
// a widening rewrite such as FOO_1 to FOO_* is an extension. A wildcard
|
|
// latched pattern not present verbatim is conservatively treated as a
|
|
// removal.
|
|
func acceptEnvNarrowed(old, fresh []string) bool {
|
|
for _, o := range old {
|
|
if slices.Contains(fresh, o) {
|
|
continue
|
|
}
|
|
if o == "*" || strings.ContainsAny(o, "*?") {
|
|
return true
|
|
}
|
|
covered := false
|
|
for _, f := range fresh {
|
|
if matchAcceptEnvPattern(f, o) {
|
|
covered = true
|
|
break
|
|
}
|
|
}
|
|
if !covered {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *conn) fetchSSHAction(ctx context.Context, url string) (*tailcfg.SSHAction, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, delegateFetchTimeout)
|
|
defer cancel()
|
|
bo := backoff.NewBackoff("fetch-ssh-action", c.logf, 10*time.Second)
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := c.srv.lb.DoNoiseRequest(req)
|
|
if err != nil {
|
|
bo.BackOff(ctx, err)
|
|
continue
|
|
}
|
|
if res.StatusCode != 200 {
|
|
body, _ := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if len(body) > 1<<10 {
|
|
body = body[:1<<10]
|
|
}
|
|
c.logf("fetch of %v: %s, %s", url, res.Status, body)
|
|
bo.BackOff(ctx, fmt.Errorf("unexpected status: %v", res.Status))
|
|
continue
|
|
}
|
|
a := new(tailcfg.SSHAction)
|
|
err = json.NewDecoder(res.Body).Decode(a)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
c.logf("invalid next SSHAction JSON from %v: %v", url, err)
|
|
bo.BackOff(ctx, err)
|
|
continue
|
|
}
|
|
return a, nil
|
|
}
|
|
}
|
|
|
|
// killProcessOnContextDone waits for ss.ctx to be done and kills the process,
|
|
// unless the process has already exited.
|
|
func (ss *sshSession) killProcessOnContextDone() {
|
|
defer close(ss.exitHandled)
|
|
<-ss.ctx.Done()
|
|
// Either the process has already exited, in which case this does nothing.
|
|
// Or, the process is still running in which case this will kill it.
|
|
ss.exitOnce.Do(func() {
|
|
err := context.Cause(ss.ctx)
|
|
if serr, ok := err.(SSHTerminationError); ok {
|
|
msg := serr.SSHTerminationMessage()
|
|
if msg != "" {
|
|
io.WriteString(ss.Stderr(), "\r\n\r\n"+msg+"\r\n\r\n")
|
|
}
|
|
}
|
|
ss.logf("terminating SSH session from %v: %v", ss.conn.info.src.Addr(), err)
|
|
// We don't need to wait for the process here, sshSession.run()
|
|
// does the waiting regardless of termination reason.
|
|
ss.hangupProcess()
|
|
})
|
|
}
|
|
|
|
// isNotFoundOrExecutable reports whether err is a launchProcess
|
|
// failure caused by the command not existing on disk.
|
|
func isNotFoundOrExecutable(err error) bool {
|
|
return errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist)
|
|
}
|
|
|
|
// attachSession registers ss as an active session.
|
|
func (c *conn) attachSession(ss *sshSession) {
|
|
c.srv.sessionWaitGroup.Add(1)
|
|
if ss.sharedID == "" {
|
|
panic("empty sharedID")
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.sessions = append(c.sessions, ss)
|
|
}
|
|
|
|
// detachSession unregisters s from the list of active sessions.
|
|
func (c *conn) detachSession(ss *sshSession) {
|
|
defer c.srv.sessionWaitGroup.Done()
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for i, s := range c.sessions {
|
|
if s == ss {
|
|
c.sessions = append(c.sessions[:i], c.sessions[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var errSessionDone = errors.New("session is done")
|
|
|
|
// run is the entrypoint for a newly accepted SSH session.
|
|
//
|
|
// It handles ss once it's been accepted and determined
|
|
// that it should run.
|
|
func (ss *sshSession) run() {
|
|
metricActiveSessions.Add(1)
|
|
defer metricActiveSessions.Add(-1)
|
|
defer ss.cancelCtx(errSessionDone)
|
|
defer ss.Close() // CHANNEL_CLOSE, last on the wire; see ss.Exit below.
|
|
|
|
if attached := ss.conn.srv.attachSessionToConnIfNotShutdown(ss); !attached {
|
|
fmt.Fprintf(ss, "Tailscale SSH is shutting down\r\n")
|
|
// Use 255 for server failures before the command starts, following
|
|
// the ssh(1) convention for SSH errors.
|
|
ss.Exit(255)
|
|
return
|
|
}
|
|
defer ss.conn.detachSession(ss)
|
|
|
|
lu := ss.conn.localUser
|
|
logf := ss.logf
|
|
|
|
if ss.conn.finalAction.SessionDuration != 0 {
|
|
t := time.AfterFunc(ss.conn.finalAction.SessionDuration, func() {
|
|
ss.cancelCtx(userVisibleError{
|
|
fmt.Sprintf("Session timeout of %v elapsed.", ss.conn.finalAction.SessionDuration),
|
|
context.DeadlineExceeded,
|
|
})
|
|
})
|
|
defer t.Stop()
|
|
}
|
|
|
|
if err := canSwitchToLocalUser(lu); err != nil {
|
|
ss.logf("%v", err)
|
|
fmt.Fprintf(ss, "can't switch user\r\n")
|
|
// The server could not start the command.
|
|
ss.Exit(255)
|
|
return
|
|
}
|
|
|
|
// Take control of the PTY so that we can configure it below.
|
|
// See https://github.com/tailscale/tailscale/issues/4146
|
|
ss.DisablePTYEmulation()
|
|
|
|
if ss.Subsystem() != "sftp" {
|
|
if err := ss.handleSSHAgentForwarding(ss, lu); err != nil {
|
|
ss.logf("agent forwarding failed: %v", err)
|
|
} else if ss.agentListener != nil {
|
|
// TODO(maisem/bradfitz): add a way to close all session resources
|
|
defer ss.closeAgentForwarding()
|
|
}
|
|
}
|
|
|
|
// Recording policy also applies to SFTP sessions, including scp -s.
|
|
// These record session metadata rather than the binary protocol; see
|
|
// recording.metadataOnly.
|
|
var rec *recording // or nil if disabled
|
|
if ss.shouldRecord() {
|
|
var err error
|
|
rec, err = ss.startNewRecording()
|
|
if err != nil {
|
|
// SFTP stdout is a binary protocol stream; report rejection on
|
|
// stderr without changing shell-session output behavior.
|
|
out := io.Writer(ss)
|
|
if ss.Subsystem() == "sftp" {
|
|
out = ss.Stderr()
|
|
}
|
|
if uve, ok := errors.AsType[userVisibleError](err); ok {
|
|
fmt.Fprintf(out, "%s\r\n", uve.SSHTerminationMessage())
|
|
} else {
|
|
fmt.Fprintf(out, "can't start new recording\r\n")
|
|
}
|
|
ss.logf("startNewRecording: %v", err)
|
|
// Exit 254 indicates that recording policy prevented session startup.
|
|
ss.Exit(254)
|
|
return
|
|
}
|
|
ss.logf("startNewRecording: <nil>")
|
|
if rec != nil {
|
|
defer rec.Close()
|
|
}
|
|
}
|
|
|
|
err := ss.launchProcess()
|
|
if err != nil {
|
|
logf("start failed: %v", err.Error())
|
|
exitCode := 1
|
|
if errors.Is(err, context.Canceled) {
|
|
cause := context.Cause(ss.ctx)
|
|
if serr, ok := cause.(SSHTerminationError); ok {
|
|
if msg := serr.SSHTerminationMessage(); msg != "" {
|
|
io.WriteString(ss.Stderr(), "\r\n\r\n"+msg+"\r\n\r\n")
|
|
}
|
|
}
|
|
} else if isNotFoundOrExecutable(err) {
|
|
// Use the POSIX shell convention for a missing command:
|
|
// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_08_02
|
|
exitCode = 127
|
|
}
|
|
ss.Exit(exitCode)
|
|
return
|
|
}
|
|
ss.exitHandled = make(chan struct{})
|
|
go ss.killProcessOnContextDone()
|
|
|
|
// Wait for both output streams before sending CHANNEL_EOF.
|
|
// Do not wait for stdin: clients may keep it open until CHANNEL_CLOSE.
|
|
// The deferred ss.Close unblocks the stdin copier.
|
|
var wg sync.WaitGroup
|
|
|
|
go func() {
|
|
defer ss.wrStdin.Close()
|
|
if _, err := io.Copy(rec.writer("i", ss.wrStdin), ss); err != nil {
|
|
logf("stdin copy: %v", err)
|
|
ss.cancelCtx(err)
|
|
}
|
|
}()
|
|
|
|
wg.Go(func() {
|
|
defer ss.rdStdout.Close()
|
|
if _, err := io.Copy(rec.writer("o", ss), ss.rdStdout); err != nil && !errors.Is(err, io.EOF) {
|
|
logf("stdout copy: %v", err)
|
|
}
|
|
})
|
|
|
|
// rdStderr is nil for ptys.
|
|
if ss.rdStderr != nil {
|
|
wg.Go(func() {
|
|
defer ss.rdStderr.Close()
|
|
// The asciinema format has no separate stderr channel. Record
|
|
// stderr as output, as for PTY sessions.
|
|
if _, err := io.Copy(rec.writer("o", ss.Stderr()), ss.rdStderr); err != nil {
|
|
logf("stderr copy: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
exitCode, err := ss.waitProcess()
|
|
|
|
if ss.ctx.Err() != nil {
|
|
// Cancellation (e.g. recording upload failure) wrote a
|
|
// termination message via killProcessOnContextDone; wait
|
|
// for it before Exit closes the channel for writes.
|
|
<-ss.exitHandled
|
|
}
|
|
|
|
// This will either make the SSH Termination goroutine be a no-op,
|
|
// or itself will be a no-op because the process was killed by the
|
|
// aforementioned goroutine.
|
|
ss.exitOnce.Do(func() {})
|
|
|
|
switch {
|
|
case err != nil:
|
|
ss.logf("Wait: %v", err)
|
|
exitCode = 1
|
|
case exitCode == 0:
|
|
ss.logf("Session complete")
|
|
default:
|
|
ss.logf("Wait: code=%v", exitCode)
|
|
}
|
|
|
|
// Order on the wire: exit-status, remaining output, EOF,
|
|
// CHANNEL_CLOSE (deferred ss.Close). RFC 4254 §6.10. CloseWrite
|
|
// (CHANNEL_EOF) must wait for both output copiers: a write on
|
|
// either stream after EOF fails and silently truncates that
|
|
// stream's tail.
|
|
ss.Exit(exitCode)
|
|
closeAll(ss.childPipes...)
|
|
wg.Wait()
|
|
ss.CloseWrite()
|
|
}
|
|
|
|
// recordSSHToLocalDisk is a deprecated dev knob to allow recording SSH sessions
|
|
// to local storage. It is only used if there is no recording configured by the
|
|
// coordination server. This will be removed in the future.
|
|
var recordSSHToLocalDisk = envknob.RegisterBool("TS_DEBUG_LOG_SSH")
|
|
|
|
// recorders returns the list of recorders to use for this session.
|
|
// If the final action has a non-empty list of recorders, that list is
|
|
// returned. Otherwise, the list of recorders from the initial action
|
|
// is returned.
|
|
func (ss *sshSession) recorders() ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) {
|
|
return recordingConfig(ss.conn.action0, ss.conn.finalAction)
|
|
}
|
|
|
|
// recordingConfig applies the delegated action's recorder override, if present.
|
|
func recordingConfig(initial, final *tailcfg.SSHAction) ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) {
|
|
if len(final.Recorders) > 0 {
|
|
return final.Recorders, final.OnRecordingFailure
|
|
}
|
|
return initial.Recorders, initial.OnRecordingFailure
|
|
}
|
|
|
|
func (ss *sshSession) shouldRecord() bool {
|
|
recs, _ := ss.recorders()
|
|
return len(recs) > 0 || recordSSHToLocalDisk()
|
|
}
|
|
|
|
type sshConnInfo struct {
|
|
// sshUser is the requested local SSH username ("root", "alice", etc).
|
|
sshUser string
|
|
|
|
// src is the Tailscale IP and port that the connection came from.
|
|
src netip.AddrPort
|
|
|
|
// dst is the Tailscale IP and port that the connection came for.
|
|
dst netip.AddrPort
|
|
|
|
// node is srcIP's node.
|
|
node tailcfg.NodeView
|
|
|
|
// uprof is node's UserProfile.
|
|
uprof tailcfg.UserProfile
|
|
}
|
|
|
|
func (ci *sshConnInfo) String() string {
|
|
return fmt.Sprintf("%v->%v@%v", ci.src, ci.sshUser, ci.dst)
|
|
}
|
|
|
|
func (c *conn) ruleExpired(r *tailcfg.SSHRule) bool {
|
|
if r.RuleExpires == nil {
|
|
return false
|
|
}
|
|
return r.RuleExpires.Before(c.srv.now())
|
|
}
|
|
|
|
func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy) (*tailcfg.SSHAction, string, []string, *tailcfg.SSHRule, evalResult) {
|
|
return c.evalSSHPolicyWithUserLookup(pol, userLookup)
|
|
}
|
|
|
|
func (c *conn) evalSSHPolicyWithUserLookup(pol *tailcfg.SSHPolicy, lookup func(string) (*userMeta, error)) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, rule *tailcfg.SSHRule, result evalResult) {
|
|
failedOnUser := false
|
|
for _, r := range pol.Rules {
|
|
if a, localUser, acceptEnv, err := c.matchRuleWithUserLookup(r, lookup); err == nil {
|
|
return a, localUser, acceptEnv, r, accepted
|
|
} else if errors.Is(err, errUserMatch) {
|
|
failedOnUser = true
|
|
}
|
|
}
|
|
result = rejected
|
|
if failedOnUser {
|
|
result = rejectedUser
|
|
}
|
|
return nil, "", nil, nil, result
|
|
}
|
|
|
|
// internal errors for testing; they don't escape to callers or logs.
|
|
var (
|
|
errNilRule = errors.New("nil rule")
|
|
errNilAction = errors.New("nil action")
|
|
errRuleExpired = errors.New("rule expired")
|
|
errPrincipalMatch = errors.New("principal didn't match")
|
|
errUserMatch = errors.New("user didn't match")
|
|
errInvalidConn = errors.New("invalid connection state")
|
|
)
|
|
|
|
func (c *conn) matchRule(r *tailcfg.SSHRule) (*tailcfg.SSHAction, string, []string, error) {
|
|
return c.matchRuleWithUserLookup(r, userLookup)
|
|
}
|
|
|
|
func (c *conn) matchRuleWithUserLookup(r *tailcfg.SSHRule, lookup func(string) (*userMeta, error)) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) {
|
|
defer func() {
|
|
c.vlogf("matchRule(%+v): %v", r, err)
|
|
}()
|
|
|
|
if c == nil {
|
|
return nil, "", nil, errInvalidConn
|
|
}
|
|
if c.info == nil {
|
|
c.logf("invalid connection state")
|
|
return nil, "", nil, errInvalidConn
|
|
}
|
|
if r == nil {
|
|
return nil, "", nil, errNilRule
|
|
}
|
|
if r.Action == nil {
|
|
return nil, "", nil, errNilAction
|
|
}
|
|
if c.ruleExpired(r) {
|
|
return nil, "", nil, errRuleExpired
|
|
}
|
|
if !c.anyPrincipalMatches(r.Principals) {
|
|
return nil, "", nil, errPrincipalMatch
|
|
}
|
|
if !r.Action.Reject {
|
|
// For all but Reject rules, SSHUsers is required.
|
|
// If SSHUsers is nil or empty, mapLocalUser will return an
|
|
// empty string anyway.
|
|
localUser = mapLocalUser(r.SSHUsers, c.info.sshUser, lookup)
|
|
if localUser == "" {
|
|
return nil, "", nil, errUserMatch
|
|
}
|
|
}
|
|
return r.Action, localUser, r.AcceptEnv, nil
|
|
}
|
|
|
|
func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string, lookup func(string) (*userMeta, error)) (localUser string) {
|
|
v, ok := ruleSSHUsers[reqSSHUser]
|
|
if !ok {
|
|
v = ruleSSHUsers["*"]
|
|
}
|
|
if v == "=" {
|
|
// Skip lookup for gokrazy as we intentionally fall back to a synthesized
|
|
// root user there when user lookup fails.
|
|
if distro.Get() == distro.Gokrazy {
|
|
return reqSSHUser
|
|
}
|
|
|
|
// Immediately look up user information for purposes of generating
|
|
// hold and delegate URL (if necessary).
|
|
lu, err := lookup(reqSSHUser)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
// Don't match as root for autogroup:nonroot
|
|
if isRootUser(lu) {
|
|
return ""
|
|
}
|
|
return reqSSHUser
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal) bool {
|
|
for _, p := range ps {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if c.principalMatchesTailscaleIdentity(p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// principalMatchesTailscaleIdentity reports whether one of p's four fields
|
|
// that match the Tailscale identity match (Node, NodeIP, UserLogin, Any).
|
|
func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool {
|
|
ci := c.info
|
|
if p.Any {
|
|
return true
|
|
}
|
|
if !p.Node.IsZero() && ci.node.Valid() && p.Node == ci.node.StableID() {
|
|
return true
|
|
}
|
|
if p.NodeIP != "" {
|
|
if ip, _ := netip.ParseAddr(p.NodeIP); ip == ci.src.Addr() {
|
|
return true
|
|
}
|
|
}
|
|
if p.UserLogin != "" && ci.uprof.LoginName == p.UserLogin {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func randBytes(n int) []byte {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (ss *sshSession) openFileForRecording(now time.Time) (_ io.WriteCloser, err error) {
|
|
varRoot := ss.conn.srv.lb.TailscaleVarRoot()
|
|
if varRoot == "" {
|
|
return nil, errors.New("no var root for recording storage")
|
|
}
|
|
dir := filepath.Join(varRoot, "ssh-sessions")
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.CreateTemp(dir, fmt.Sprintf("ssh-session-%v-*.cast", now.UnixNano()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// startNewRecording starts a new SSH session recording.
|
|
// It may return a nil recording if recording is not available.
|
|
func (ss *sshSession) startNewRecording() (_ *recording, err error) {
|
|
// We store the node key as soon as possible when creating
|
|
// a new recording incase of FUS.
|
|
nodeKey := ss.conn.srv.lb.NodeKey()
|
|
if nodeKey.IsZero() {
|
|
return nil, errors.New("ssh server is unavailable: no node key")
|
|
}
|
|
|
|
recorders, onFailure := ss.recorders()
|
|
var localRecording bool
|
|
if len(recorders) == 0 {
|
|
if recordSSHToLocalDisk() {
|
|
localRecording = true
|
|
} else {
|
|
return nil, errors.New("no recorders configured")
|
|
}
|
|
}
|
|
|
|
var w gliderssh.Window
|
|
if ptyReq, _, isPtyReq := ss.Pty(); isPtyReq {
|
|
w = ptyReq.Window
|
|
}
|
|
|
|
term := envValFromList(ss.Environ(), "TERM")
|
|
if term == "" {
|
|
term = "xterm-256color" // something non-empty
|
|
}
|
|
|
|
now := time.Now()
|
|
rec := &recording{
|
|
ss: ss,
|
|
start: now,
|
|
failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "",
|
|
// Subsystem sessions (sftp) record metadata only; see the
|
|
// recording.metadataOnly doc comment.
|
|
metadataOnly: ss.Subsystem() != "",
|
|
}
|
|
|
|
// We want to use a background context for uploading and not ss.ctx.
|
|
// ss.ctx is closed when the session closes, but we don't want to break the upload at that time.
|
|
// Instead we want to wait for the session to close the writer when it finishes.
|
|
ctx := context.Background()
|
|
if localRecording {
|
|
rec.out, err = ss.openFileForRecording(now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
var errChan <-chan error
|
|
var attempts []*tailcfg.SSHRecordingAttempt
|
|
rec.out, attempts, errChan, err = sessionrecording.ConnectToRecorder(ctx, recorders, ss.conn.srv.lb.Dialer().UserDial)
|
|
if err != nil {
|
|
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
|
eventType := tailcfg.SSHSessionRecordingFailed
|
|
if onFailure.RejectSessionWithMessage != "" {
|
|
eventType = tailcfg.SSHSessionRecordingRejected
|
|
}
|
|
ss.notifyControl(ctx, nodeKey, eventType, attempts, onFailure.NotifyURL)
|
|
}
|
|
|
|
if onFailure != nil && onFailure.RejectSessionWithMessage != "" {
|
|
ss.logf("recording: error starting recording (rejecting session): %v", err)
|
|
return nil, userVisibleError{
|
|
error: err,
|
|
msg: onFailure.RejectSessionWithMessage,
|
|
}
|
|
}
|
|
ss.logf("recording: error starting recording (failing open): %v", err)
|
|
return nil, nil
|
|
}
|
|
go func() {
|
|
err := <-errChan
|
|
if err == nil {
|
|
select {
|
|
case <-ss.ctx.Done():
|
|
// Success.
|
|
ss.logf("recording: finished uploading recording")
|
|
return
|
|
default:
|
|
err = errors.New("recording upload ended before the SSH session")
|
|
}
|
|
}
|
|
terminate := onFailure != nil && onFailure.TerminateSessionWithMessage != ""
|
|
if terminate {
|
|
ss.logf("recording: error uploading recording (closing session): %v", err)
|
|
ss.cancelCtx(userVisibleError{
|
|
error: err,
|
|
msg: onFailure.TerminateSessionWithMessage,
|
|
})
|
|
}
|
|
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
|
lastAttempt := attempts[len(attempts)-1]
|
|
lastAttempt.FailureMessage = err.Error()
|
|
|
|
eventType := tailcfg.SSHSessionRecordingFailed
|
|
if terminate {
|
|
eventType = tailcfg.SSHSessionRecordingTerminated
|
|
}
|
|
|
|
ss.notifyControl(ctx, nodeKey, eventType, attempts, onFailure.NotifyURL)
|
|
}
|
|
if terminate {
|
|
return
|
|
}
|
|
ss.logf("recording: error uploading recording (failing open): %v", err)
|
|
}()
|
|
}
|
|
|
|
// The requested subsystem (e.g. "sftp"), or "" for interactive
|
|
// sessions. Subsystem requests carry no command string on the wire,
|
|
// so the cast header records the subsystem name instead — this is
|
|
// what identifies an SFTP file-transfer session.
|
|
subsystem := ss.Subsystem()
|
|
|
|
ch := sessionrecording.CastHeader{
|
|
Version: 2,
|
|
Width: w.Width,
|
|
Height: w.Height,
|
|
Timestamp: now.Unix(),
|
|
// Record the raw client-provided command, not a shell-parsed
|
|
// reconstruction: the SSH command is passed to the user's shell
|
|
// verbatim, and the shell lexer silently drops unbalanced quotes,
|
|
// which could otherwise be used to make the recording show a
|
|
// different command than the one that ran. Subsystem sessions
|
|
// have no command; the subsystem name above is recorded instead.
|
|
Command: cmp.Or(subsystem, ss.RawCommand()),
|
|
Env: map[string]string{
|
|
"TERM": term,
|
|
// TODO(bradfitz): anything else important?
|
|
// including all seems noisey, but maybe we should
|
|
// for auditing. But first need to break
|
|
// launchProcess's startWithStdPipes and
|
|
// startWithPTY up so that they first return the cmd
|
|
// without starting it, and then a step that starts
|
|
// it. Then we can (1) make the cmd, (2) start the
|
|
// recording, (3) start the process.
|
|
},
|
|
SSHUser: ss.conn.info.sshUser,
|
|
LocalUser: ss.conn.localUser.Username,
|
|
SrcNode: strings.TrimSuffix(ss.conn.info.node.Name(), "."),
|
|
SrcNodeID: ss.conn.info.node.StableID(),
|
|
ConnectionID: ss.conn.connID,
|
|
}
|
|
if !ss.conn.info.node.IsTagged() {
|
|
ch.SrcNodeUser = ss.conn.info.uprof.LoginName
|
|
ch.SrcNodeUserID = ss.conn.info.node.User()
|
|
} else {
|
|
ch.SrcNodeTags = ss.conn.info.node.Tags().AsSlice()
|
|
}
|
|
j, err := json.Marshal(ch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
j = append(j, '\n')
|
|
if _, err := rec.out.Write(j); err != nil {
|
|
if errors.Is(err, io.ErrClosedPipe) && ss.ctx.Err() != nil {
|
|
// If we got an io.ErrClosedPipe, it's likely because
|
|
// the recording server closed the connection on us. Return
|
|
// the original context error instead.
|
|
return nil, context.Cause(ss.ctx)
|
|
}
|
|
return nil, err
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// notifyControl sends a SSHEventNotifyRequest to control over noise.
|
|
// A SSHEventNotifyRequest is sent when an action or state reached during
|
|
// an SSH session is a defined EventType. Each notification is bounded so
|
|
// an unresponsive control request cannot indefinitely block session startup
|
|
// after a recorder connection failure or retain an upload watcher. The
|
|
// timeout is local to the notification, not the recording upload or session.
|
|
func (ss *sshSession) notifyControl(ctx context.Context, nodeKey key.NodePublic, notifyType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, url string) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
re := tailcfg.SSHEventNotifyRequest{
|
|
EventType: notifyType,
|
|
ConnectionID: ss.conn.connID,
|
|
CapVersion: tailcfg.CurrentCapabilityVersion,
|
|
NodeKey: nodeKey,
|
|
SrcNode: ss.conn.info.node.ID(),
|
|
SSHUser: ss.conn.info.sshUser,
|
|
LocalUser: ss.conn.localUser.Username,
|
|
RecordingAttempts: attempts,
|
|
}
|
|
|
|
body, err := json.Marshal(re)
|
|
if err != nil {
|
|
ss.logf("notifyControl: unable to marshal SSHNotifyRequest:", err)
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, httpm.POST, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
ss.logf("notifyControl: unable to create request:", err)
|
|
return
|
|
}
|
|
|
|
resp, err := ss.conn.srv.lb.DoNoiseRequest(req)
|
|
if err != nil {
|
|
ss.logf("notifyControl: unable to send noise request:", err)
|
|
return
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
ss.logf("notifyControl: noise request returned status code %v", resp.StatusCode)
|
|
return
|
|
}
|
|
}
|
|
|
|
// recording is the state for an SSH session recording.
|
|
type recording struct {
|
|
ss *sshSession
|
|
start time.Time
|
|
|
|
// failOpen specifies whether the session should be allowed to
|
|
// continue if writing to the recording fails.
|
|
failOpen bool
|
|
|
|
// metadataOnly disables I/O recording for subsystem sessions (SFTP).
|
|
// The cast header identifies the peer, local user, connection, and
|
|
// subsystem. Upload and recording-failure policy still apply.
|
|
metadataOnly bool
|
|
|
|
mu sync.Mutex // guards writes to, close of out
|
|
out io.WriteCloser
|
|
}
|
|
|
|
func (r *recording) Close() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.out == nil {
|
|
return nil
|
|
}
|
|
err := r.out.Close()
|
|
r.out = nil
|
|
return err
|
|
}
|
|
|
|
// writer returns an io.Writer around w that first records the write.
|
|
//
|
|
// The dir should be "i" for input or "o" for output.
|
|
//
|
|
// If r is nil, it returns w unchanged.
|
|
//
|
|
// Currently (2023-03-21) we only record output, not input.
|
|
func (r *recording) writer(dir string, w io.Writer) io.Writer {
|
|
if r == nil {
|
|
return w
|
|
}
|
|
if r.metadataOnly {
|
|
// Metadata-only recordings (sftp) capture no I/O events.
|
|
return w
|
|
}
|
|
if dir == "i" {
|
|
// TODO: record input? Maybe not, since it might contain
|
|
// passwords.
|
|
return w
|
|
}
|
|
return &loggingWriter{r: r, dir: dir, w: w}
|
|
}
|
|
|
|
// loggingWriter is an io.Writer wrapper that writes first an
|
|
// asciinema JSON cast format recording line, and then writes to w.
|
|
type loggingWriter struct {
|
|
r *recording
|
|
dir string // "i" or "o" (input or output)
|
|
w io.Writer // underlying Writer, after writing to r.out
|
|
|
|
// recordingFailedOpen specifies whether we've failed to write to
|
|
// r.out and should stop trying. It is set to true if we fail to write
|
|
// to r.out and r.failOpen is set.
|
|
recordingFailedOpen bool
|
|
}
|
|
|
|
func (w *loggingWriter) Write(p []byte) (n int, err error) {
|
|
if !w.recordingFailedOpen {
|
|
j, err := json.Marshal([]any{
|
|
time.Since(w.r.start).Seconds(),
|
|
w.dir,
|
|
string(p),
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
j = append(j, '\n')
|
|
if err := w.writeCastLine(j); err != nil {
|
|
if !w.r.failOpen {
|
|
return 0, err
|
|
}
|
|
w.recordingFailedOpen = true
|
|
}
|
|
}
|
|
return w.w.Write(p)
|
|
}
|
|
|
|
func (w loggingWriter) writeCastLine(j []byte) error {
|
|
w.r.mu.Lock()
|
|
defer w.r.mu.Unlock()
|
|
if w.r.out == nil {
|
|
return errors.New("logger closed")
|
|
}
|
|
_, err := w.r.out.Write(j)
|
|
if err != nil {
|
|
return fmt.Errorf("logger Write: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func envValFromList(env []string, wantKey string) (v string) {
|
|
for _, kv := range env {
|
|
if thisKey, v, ok := strings.Cut(kv, "="); ok && envEq(thisKey, wantKey) {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// envEq reports whether environment variable a == b for the current
|
|
// operating system.
|
|
func envEq(a, b string) bool {
|
|
//lint:ignore SA4032 in case this func moves elsewhere, permit the GOOS check
|
|
if runtime.GOOS == "windows" {
|
|
return strings.EqualFold(a, b)
|
|
}
|
|
return a == b
|
|
}
|
|
|
|
var (
|
|
metricActiveSessions = clientmetric.NewGauge("ssh_active_sessions")
|
|
metricIncomingConnections = clientmetric.NewCounter("ssh_incoming_connections")
|
|
metricTerminalAccept = clientmetric.NewCounter("ssh_terminalaction_accept")
|
|
metricTerminalReject = clientmetric.NewCounter("ssh_terminalaction_reject")
|
|
metricTerminalMalformed = clientmetric.NewCounter("ssh_terminalaction_malformed")
|
|
metricTerminalFetchError = clientmetric.NewCounter("ssh_terminalaction_fetch_error")
|
|
metricHolds = clientmetric.NewCounter("ssh_holds")
|
|
metricPolicyChangeKick = clientmetric.NewCounter("ssh_policy_change_kick")
|
|
metricSFTP = clientmetric.NewCounter("ssh_sftp_sessions")
|
|
metricLocalPortForward = clientmetric.NewCounter("ssh_local_port_forward_requests")
|
|
metricRemotePortForward = clientmetric.NewCounter("ssh_remote_port_forward_requests")
|
|
)
|
|
|
|
// userVisibleError is a wrapper around an error that implements
|
|
// SSHTerminationError, so msg is written to their session.
|
|
type userVisibleError struct {
|
|
msg string
|
|
error
|
|
}
|
|
|
|
func (ue userVisibleError) SSHTerminationMessage() string { return ue.msg }
|
|
|
|
// SSHTerminationError is implemented by errors that terminate an SSH
|
|
// session and should be written to user's sessions.
|
|
type SSHTerminationError interface {
|
|
error
|
|
SSHTerminationMessage() string
|
|
}
|
|
|
|
func closeAll(cs ...io.Closer) {
|
|
for _, c := range cs {
|
|
if c != nil {
|
|
c.Close()
|
|
}
|
|
}
|
|
}
|