sing-box/protocol/tailscale/tailssh/server.go
2026-06-25 17:38:51 +08:00

983 lines
30 KiB
Go

//go:build with_gvisor
package tailssh
import (
"context"
"crypto/ed25519"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
tsDNS "github.com/sagernet/tailscale/net/dns"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/tsnet"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
"github.com/pkg/sftp"
gossh "golang.org/x/crypto/ssh"
)
type sshConnContextKey struct{}
type sshConnInfo struct {
node tailcfg.NodeView
userProfile tailcfg.UserProfile
sshUser string
srcIP netip.Addr
localUser string
action *tailcfg.SSHAction
acceptEnv []string
// action0 is the initially matched rule's action, retained so session
// recording can fall back to its recorders when a hold-and-delegate result
// (which replaces action) carries none. connID is shared with control and
// reused across multiplexed sessions on this connection.
action0 *tailcfg.SSHAction
connID string
// localUser is fixed for the lifetime of an accepted connection, so the OS
// lookup is resolved once and memoized here for all sessions/forwards.
localUserOnce sync.Once
localUserInfo *adapter.PlatformUser
localUserErr error
}
type Server struct {
tsnetServer *tsnet.Server
platformInterface adapter.PlatformInterface
logger logger.ContextLogger
listener net.Listener
server *gliderssh.Server
backend shellBackend
hostSigner gossh.Signer
disablePTY bool
disableSFTP bool
disableForwarding bool
done chan struct{}
serverCtx context.Context
serverCancel context.CancelFunc
access sync.Mutex
activeConns map[*activeSession]struct{}
sessionWg sync.WaitGroup
}
// activeSession is the map key for activeConns so that multiple concurrent
// sessions sharing one *sshConnInfo are tracked and revoked independently.
type activeSession struct {
info *sshConnInfo
cancel context.CancelFunc
}
func New(tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) {
s := &Server{
tsnetServer: tsnetServer,
platformInterface: platformInterface,
logger: logger,
disablePTY: options.DisablePTY,
disableSFTP: options.DisableSFTP,
disableForwarding: options.DisableForwarding,
done: make(chan struct{}),
activeConns: make(map[*activeSession]struct{}),
}
s.serverCtx, s.serverCancel = context.WithCancel(context.Background())
hostSigner, err := s.loadOrGenerateHostKey()
if err != nil {
return nil, err
}
s.hostSigner = hostSigner
s.backend = selectShellBackend(platformInterface)
return s, nil
}
func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) {
if s.platformInterface != nil {
keyData, err := s.platformInterface.ReadSystemSSHHostKey()
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key via platform")
return signer, nil
}
s.logger.Warn("failed to parse SSH host key from platform: ", parseErr)
}
}
// Read the system host key when privileged, but never write back to it: the
// generated key below always goes to the tsnet directory, so a parse failure
// can never clobber the operating system's sshd host key.
if isPrivilegedUser() {
systemKey := systemHostKeyPath()
if systemKey != "" {
keyData, err := os.ReadFile(systemKey)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", systemKey)
return signer, nil
}
s.logger.Warn("failed to parse system SSH host key: ", parseErr)
}
}
}
keyPath := filepath.Join(s.tsnetServer.Dir, "ssh_host_ed25519_key")
keyData, err := os.ReadFile(keyPath)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", keyPath)
return signer, nil
}
s.logger.Warn("failed to parse SSH host key, regenerating: ", parseErr)
}
_, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, err
}
keyBytes, err := gossh.MarshalPrivateKey(privateKey, "")
if err != nil {
return nil, err
}
pemData := pem.EncodeToMemory(keyBytes)
dir := filepath.Dir(keyPath)
err = os.MkdirAll(dir, 0o700)
if err != nil {
return nil, err
}
err = os.WriteFile(keyPath, pemData, 0o600)
if err != nil {
return nil, err
}
s.logger.Info("generated SSH host key at ", keyPath)
return gossh.NewSignerFromKey(privateKey)
}
func (s *Server) Start() error {
listener, err := s.tsnetServer.Listen("tcp", ":22")
if err != nil {
return err
}
s.listener = listener
fwdHandler := &gliderssh.ForwardedTCPHandler{}
unixFwdHandler := &gliderssh.ForwardedUnixHandler{}
sshServer := &gliderssh.Server{
Version: "sing-box",
ServerConfigCallback: s.serverConfig,
Handler: s.handleSession,
SubsystemHandlers: map[string]gliderssh.SubsystemHandler{
"sftp": s.handleSession,
},
ChannelHandlers: map[string]gliderssh.ChannelHandler{
"direct-tcpip": gliderssh.DirectTCPIPHandler,
"direct-streamlocal@openssh.com": gliderssh.DirectStreamLocalHandler,
},
RequestHandlers: map[string]gliderssh.RequestHandler{
"tcpip-forward": fwdHandler.HandleSSHRequest,
"cancel-tcpip-forward": fwdHandler.HandleSSHRequest,
"streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
"cancel-streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
},
LocalPortForwardingCallback: s.allowLocalForward,
ReversePortForwardingCallback: s.allowReverseForward,
}
if s.disablePTY {
sshServer.PtyCallback = func(ctx gliderssh.Context, pty gliderssh.Pty) bool {
return false
}
}
if !s.disableForwarding {
sshServer.LocalUnixForwardingCallback = s.allowLocalUnixForward
sshServer.ReverseUnixForwardingCallback = s.allowReverseUnixForward
}
maps.Copy(sshServer.RequestHandlers, gliderssh.DefaultRequestHandlers)
maps.Copy(sshServer.ChannelHandlers, gliderssh.DefaultChannelHandlers)
maps.Copy(sshServer.SubsystemHandlers, gliderssh.DefaultSubsystemHandlers)
sshServer.AddHostKey(s.hostSigner)
s.server = sshServer
hostKeyPublic := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(s.hostSigner.PublicKey())))
s.tsnetServer.ExportLocalBackend().SetExternalSSHHostKeys([]string{hostKeyPublic})
go func() {
err := sshServer.Serve(listener)
if err != nil && !errors.Is(err, gliderssh.ErrServerClosed) {
s.logger.Error("SSH server stopped: ", err)
}
}()
s.logger.Info("SSH server started on :22")
return nil
}
func (s *Server) Close() error {
close(s.done)
s.serverCancel()
s.access.Lock()
for active := range s.activeConns {
active.cancel()
}
s.access.Unlock()
var err error
if s.server != nil {
err = s.server.Close()
}
if s.listener != nil {
s.listener.Close()
}
s.sessionWg.Wait()
if s.backend != nil {
s.backend.Close()
}
return err
}
func (s *Server) serverConfig(ctx gliderssh.Context) *gossh.ServerConfig {
config := &gossh.ServerConfig{
NoClientAuthCallback: func(conn gossh.ConnMetadata) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PasswordCallback: func(conn gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
BannerCallback: func(conn gossh.ConnMetadata) string {
connInfo := s.connInfoFromContext(ctx)
if connInfo != nil && connInfo.action.Message != "" {
return connInfo.action.Message
}
return ""
},
}
return config
}
func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (*gossh.Permissions, error) {
if s.connInfoFromContext(ctx) != nil {
return &gossh.Permissions{}, nil
}
remoteAddrPort := M.AddrPortFromNet(conn.RemoteAddr())
localBackend := s.tsnetServer.ExportLocalBackend()
node, userProfile, found := localBackend.WhoIs("tcp", remoteAddrPort)
// Every denial returns an empty *gossh.PartialSuccessError so x/crypto/ssh
// stops offering further auth methods instead of re-running policy
// evaluation (and hold-and-delegate) once per method.
if !found {
s.logger.Warn("SSH auth: unknown peer ", remoteAddrPort)
return nil, &gossh.PartialSuccessError{}
}
netMap := localBackend.NetMap()
if netMap == nil || netMap.SSHPolicy == nil {
s.logger.Warn("SSH auth: no SSH policy")
return nil, &gossh.PartialSuccessError{}
}
srcIP := remoteAddrPort.Addr()
connInfo, err := s.evaluatePolicy(netMap.SSHPolicy, conn.User(), node, userProfile, srcIP)
if err != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User(), ": ", err)
return nil, &gossh.PartialSuccessError{}
}
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.action0 = connInfo.action
for hops := 0; connInfo.action.HoldAndDelegate != ""; hops++ {
if hops >= 10 {
s.logger.Info("SSH auth rejected: hold-and-delegate chain too long")
return nil, &gossh.PartialSuccessError{}
}
delegatedAction, delegateErr := s.holdAndDelegate(ctx, connInfo.action, node, conn.User(), connInfo.localUser, srcIP)
if delegateErr != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, ": ", delegateErr)
return nil, &gossh.PartialSuccessError{}
}
connInfo.action = delegatedAction
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
}
if !connInfo.action.Accept {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.sshUser = conn.User()
connInfo.srcIP = srcIP
connInfo.connID = newConnID()
ctx.SetValue(sshConnContextKey{}, connInfo)
s.logger.Info("SSH auth accepted: ", userProfile.LoginName, " -> ", connInfo.localUser)
return &gossh.Permissions{}, nil
}
func (s *Server) evaluatePolicy(policy *tailcfg.SSHPolicy, sshUser string, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) (*sshConnInfo, error) {
now := time.Now()
for _, rule := range policy.Rules {
if rule.RuleExpires != nil && now.After(*rule.RuleExpires) {
continue
}
if !s.matchPrincipals(rule.Principals, node, userProfile, srcIP) {
continue
}
if rule.Action == nil {
continue
}
if rule.Action.Reject {
return &sshConnInfo{
node: node,
userProfile: userProfile,
action: rule.Action,
}, nil
}
localUser := s.matchSSHUser(rule.SSHUsers, sshUser)
if localUser == "" {
continue
}
return &sshConnInfo{
node: node,
userProfile: userProfile,
localUser: localUser,
action: rule.Action,
acceptEnv: rule.AcceptEnv,
}, nil
}
return nil, E.New("no matching SSH rule")
}
func (s *Server) matchPrincipals(principals []*tailcfg.SSHPrincipal, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) bool {
for _, p := range principals {
if p == nil {
continue
}
if p.Any {
return true
}
if p.Node != "" && p.Node == node.StableID() {
return true
}
if p.NodeIP != "" {
principalIP, err := netip.ParseAddr(p.NodeIP)
if err == nil && principalIP == srcIP {
return true
}
}
if p.UserLogin != "" && p.UserLogin == userProfile.LoginName {
return true
}
}
return false
}
func (s *Server) matchSSHUser(sshUsers map[string]string, requestedUser string) string {
localUser, ok := sshUsers[requestedUser]
if !ok {
localUser, ok = sshUsers["*"]
if !ok {
return ""
}
}
if localUser == "" {
return ""
}
if localUser == "=" {
return requestedUser
}
return localUser
}
func (s *Server) holdAndDelegate(ctx context.Context, action *tailcfg.SSHAction, node tailcfg.NodeView, sshUser string, localUser string, srcIP netip.Addr) (*tailcfg.SSHAction, error) {
lb := s.tsnetServer.ExportLocalBackend()
delegateURL := action.HoldAndDelegate
addr4, addr6 := s.tsnetServer.TailscaleIPs()
dstNodeIP := addr4
if !dstNodeIP.IsValid() {
dstNodeIP = addr6
}
srcNodeIP := srcIP
if !srcNodeIP.IsValid() && node.Addresses().Len() > 0 {
srcNodeIP = node.Addresses().At(0).Addr()
}
var dstNodeID string
netMap := lb.NetMap()
if netMap != nil && netMap.SelfNode.Valid() {
dstNodeID = fmt.Sprint(int64(netMap.SelfNode.ID()))
}
// Escape interpolated values; $SSH_USER and $LOCAL_USER are client-controlled
// (matchSSHUser "=" passes the requested name through). Numeric node IDs need
// no escaping.
replacer := strings.NewReplacer(
"$SRC_NODE_IP", url.QueryEscape(srcNodeIP.String()),
"$SRC_NODE_ID", fmt.Sprint(int64(node.ID())),
"$DST_NODE_IP", url.QueryEscape(dstNodeIP.String()),
"$DST_NODE_ID", dstNodeID,
"$SSH_USER", url.QueryEscape(sshUser),
"$LOCAL_USER", url.QueryEscape(localUser),
)
delegateURL = replacer.Replace(delegateURL)
deadline := time.After(30 * time.Minute)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-s.done:
return nil, E.New("server closing")
case <-deadline:
return nil, E.New("hold and delegate timed out")
default:
}
req, err := http.NewRequestWithContext(ctx, "GET", delegateURL, nil)
if err != nil {
return nil, err
}
resp, err := lb.DoNoiseRequest(req)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
s.logger.Warn("hold and delegate: unexpected status ", resp.Status)
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
var newAction tailcfg.SSHAction
err = json.Unmarshal(body, &newAction)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
return &newAction, nil
}
}
// delegateBackoff waits up to a second between hold-and-delegate retries,
// returning a non-nil error (so the caller never returns a nil action) when the
// connection or the server is shutting down.
func (s *Server) delegateBackoff(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-s.done:
return E.New("server closing")
case <-time.After(time.Second):
return nil
}
}
func (s *Server) connInfoFromContext(ctx gliderssh.Context) *sshConnInfo {
val := ctx.Value(sshConnContextKey{})
if val == nil {
return nil
}
return val.(*sshConnInfo)
}
func (s *Server) resolveConnUser(connInfo *sshConnInfo) (*adapter.PlatformUser, error) {
connInfo.localUserOnce.Do(func() {
connInfo.localUserInfo, connInfo.localUserErr = resolveLocalUser(s.platformInterface, connInfo.localUser)
})
return connInfo.localUserInfo, connInfo.localUserErr
}
func (s *Server) handleSession(session gliderssh.Session) {
connInfo := s.connInfoFromContext(session.Context())
s.sessionWg.Add(1)
defer s.sessionWg.Done()
ctx, cancel := context.WithCancel(session.Context())
defer cancel()
active := &activeSession{info: connInfo, cancel: cancel}
s.access.Lock()
s.activeConns[active] = struct{}{}
s.access.Unlock()
defer func() {
s.access.Lock()
delete(s.activeConns, active)
s.access.Unlock()
}()
if connInfo.action.SessionDuration != 0 {
timer := time.AfterFunc(connInfo.action.SessionDuration, func() {
io.WriteString(session.Stderr(), "Session duration exceeded.\r\n")
cancel()
})
defer timer.Stop()
}
subsystem := session.Subsystem()
if subsystem == "sftp" {
s.handleSFTP(ctx, session, connInfo)
return
}
if subsystem != "" {
fmt.Fprintf(session.Stderr(), "unsupported subsystem: %s\r\n", subsystem)
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
err = verifyShellIdentity(localUser)
if err != nil {
s.logger.Warn("shell rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
var agentSocketPath string
if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) {
agentListener, listenErr := gliderssh.NewAgentListener()
if listenErr == nil {
defer agentListener.Close()
agentSocketPath = agentListener.Addr().String()
// The agent socket is created as the server identity; hand it to the
// target user so SSH_AUTH_SOCK is reachable after privileges drop.
prepareErr := prepareAgentSocket(agentSocketPath, localUser.Uid, localUser.Gid)
if prepareErr != nil {
s.logger.Warn("prepare agent socket: ", prepareErr)
}
go gliderssh.ForwardAgentConnections(agentListener, session)
}
}
env := s.buildEnvironment(session, connInfo, localUser)
if agentSocketPath != "" {
env = append(env, "SSH_AUTH_SOCK="+agentSocketPath)
}
ptyReq, winCh, isPty := session.Pty()
session.DisablePTYEmulation()
command := session.RawCommand()
var term string
var rows, cols uint16
if isPty {
term = ptyReq.Term
rows = clampWindowDimension(ptyReq.Window.Height)
cols = clampWindowDimension(ptyReq.Window.Width)
}
var rec *recording
recorderList, onFailure := recorders(connInfo)
if len(recorderList) > 0 {
rec, err = s.startNewRecording(ctx, cancel, session, connInfo, localUser, recorderList, onFailure)
if err != nil {
var rejected *recordingRejectedError
if errors.As(err, &rejected) && rejected.message != "" {
io.WriteString(session.Stderr(), rejected.message+"\r\n")
}
s.logger.Error("recording: ", err)
session.Exit(1)
return
}
if rec != nil {
defer rec.Close()
// Cancel the session ctx before the recording is closed (defers run LIFO,
// so this runs first), so the upload watcher observes the session as ended
// on a clean final flush instead of misreading it as a mid-session upload
// failure.
defer cancel()
}
}
shellSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: command,
Env: env,
Term: term,
Rows: rows,
Cols: cols,
})
if err != nil {
s.logger.Error("failed to open shell session: ", err)
fmt.Fprintf(session.Stderr(), "failed to open shell: %s\r\n", err)
session.Exit(1)
return
}
var shellAccess sync.Mutex
shellAlive := true
// Buffer to gliderssh's maxSigBufSize so the goroutine it spawns to replay
// buffered signals (one unconditional blocking send per signal) can never wedge
// if this connection ends before the drain goroutine consumes them all.
sigCh := make(chan gliderssh.Signal, 128)
session.Signals(sigCh)
// gliderssh delivers signals synchronously from its single per-session request
// loop while holding the session lock; an undrained sigCh blocks that loop and
// deadlocks Exit, which needs the same lock. Drain for the whole connection
// lifetime; sigCh is never closed by gliderssh, so stop on the connection context.
go func() {
for {
select {
case <-session.Context().Done():
return
case sig := <-sigCh:
sysSig := sshSignalToSyscall(sig)
if sysSig == 0 {
continue
}
shellAccess.Lock()
if shellAlive {
shellSession.Signal(sysSig)
}
shellAccess.Unlock()
}
}
}()
if isPty && winCh != nil {
// winCh (buffer 1) is fed synchronously from the same request loop and closed
// by gliderssh when the loop ends. Drain to completion: stopping early blocks
// the loop on a full winCh and leaks its goroutine.
go func() {
for win := range winCh {
shellAccess.Lock()
if shellAlive {
shellSession.Resize(clampWindowDimension(win.Height), clampWindowDimension(win.Width))
}
shellAccess.Unlock()
}
}()
}
s.pumpSession(ctx, session, shellSession, rec)
// Mark the shell closed under shellAccess so the drain goroutines never touch it
// after Close (Windows process-handle use-after-close, pty fd resize race), then
// close. The goroutines keep draining their gliderssh-owned channels until the
// request loop ends (winCh close) and the connection closes (session context done).
shellAccess.Lock()
shellAlive = false
shellSession.Close()
shellAccess.Unlock()
}
// pumpSession copies between the SSH channel and the backend session. It signals
// stdin EOF to the child (without killing it) when the client closes its input,
// and waits for all output to drain before reporting the exit status, because
// gliderssh closes the channel immediately after Exit returns.
func (s *Server) pumpSession(ctx context.Context, session gliderssh.Session, shell shellSession, rec *recording) {
go func() {
io.Copy(shell, session)
shell.CloseWrite()
}()
outputDone := make(chan struct{})
go func() {
io.Copy(rec.writer(session), shell)
close(outputDone)
}()
exitCh := make(chan uint32, 1)
go func() {
exitStatus, err := shell.Wait()
if err != nil {
s.logger.Error("wait session: ", err)
exitStatus = 1
}
exitCh <- exitStatus
}()
select {
case <-ctx.Done():
session.Exit(130)
case exitStatus := <-exitCh:
select {
case <-outputDone:
case <-ctx.Done():
}
session.Exit(int(exitStatus))
}
}
func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, connInfo *sshConnInfo) {
if s.disableSFTP {
fmt.Fprint(session.Stderr(), "SFTP is disabled.\r\n")
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
sftpPath, err := lookupSFTPServer(s.platformInterface)
if err != nil {
match, matchErr := requestedUserMatchesProcess(localUser)
if matchErr != nil {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": ", matchErr)
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
if !match {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": running process identity differs from requested user")
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
s.logger.Debug("sftp-server not found, using builtin: ", err)
s.serveBuiltinSFTP(ctx, session, localUser)
return
}
err = verifyShellIdentity(localUser)
if err != nil {
s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
env := s.buildEnvironment(session, connInfo, localUser)
sftpSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: sftpCommand(sftpPath),
Env: env,
})
if err != nil {
s.logger.Error("failed to start sftp-server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
// Use the cancelable child ctx (not session.Context()) so SessionDuration and
// OnReconfig revocation also terminate SFTP transfers.
s.pumpSession(ctx, session, sftpSession, nil)
sftpSession.Close()
}
func (s *Server) serveBuiltinSFTP(ctx context.Context, session gliderssh.Session, user *adapter.PlatformUser) {
// The builtin server runs in-process with no chroot/jail; WithServerWorkingDirectory
// only sets a default for relative paths, so absolute paths are unconfined. The
// caller only reaches here when the target user matches the process identity, so
// this grants no access beyond what the running process already has.
var opts []sftp.ServerOption
if user != nil && user.HomeDir != "" {
opts = append(opts, sftp.WithServerWorkingDirectory(user.HomeDir))
}
server, err := sftp.NewServer(session, opts...)
if err != nil {
s.logger.Error("create builtin sftp server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
defer server.Close()
// Terminate the transfer when the session ctx is cancelled (SessionDuration
// elapsed or OnReconfig revoked access): closing the SSH channel unblocks Serve.
stop := context.AfterFunc(ctx, func() {
session.Close()
})
defer stop()
err = server.Serve()
if err != nil && !errors.Is(err, io.EOF) {
s.logger.Error("builtin sftp serve: ", err)
session.Exit(1)
return
}
session.Exit(0)
}
func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser) []string {
var env []string
env = append(env,
"USER="+localUser.Username,
"HOME="+localUser.HomeDir,
"SHELL="+localUser.Shell,
"PATH="+defaultPathEnv(),
)
env = append(env, platformEnvironment(localUser)...)
remoteAddr := session.RemoteAddr()
localAddr := session.LocalAddr()
if remoteAddr != nil && localAddr != nil {
remoteHost, remotePort, _ := net.SplitHostPort(remoteAddr.String())
localHost, localPort, _ := net.SplitHostPort(localAddr.String())
env = append(env,
"SSH_CLIENT="+remoteHost+" "+remotePort+" "+localPort,
"SSH_CONNECTION="+remoteHost+" "+remotePort+" "+localHost+" "+localPort,
)
}
ptyReq, _, isPty := session.Pty()
if isPty {
env = append(env, "TERM="+ptyReq.Term)
}
// Only honor the rule's AcceptEnv patterns when the node has the ssh-env-vars
// capability, matching upstream's capability gate.
acceptEnv := connInfo.acceptEnv
if len(acceptEnv) > 0 {
netMap := s.tsnetServer.ExportLocalBackend().NetMap()
if netMap == nil || !netMap.HasCap(tailcfg.NodeAttrSSHEnvironmentVariables) {
acceptEnv = nil
}
}
for _, clientEnv := range session.Environ() {
name, _, found := strings.Cut(clientEnv, "=")
if !found {
continue
}
// TERM is already set authoritatively from the PTY request above; skip a
// client-sent duplicate that would otherwise override it.
if isPty && name == "TERM" {
continue
}
if s.envAccepted(name, acceptEnv) {
env = append(env, clientEnv)
}
}
return env
}
func (s *Server) envAccepted(name string, extraPatterns []string) bool {
// Never forward loader/shell-init variables, even if an AcceptEnv pattern
// (e.g. "LD_*" or "*") would match: they allow code execution in a shell that
// may run as another local user.
if isDangerousEnv(name) {
return false
}
// Never let a client override the variables the server sets authoritatively from
// the resolved local user: a forwarded PATH/HOME/SHELL would otherwise win (execve
// resolves duplicate keys last) and redirect command or identity resolution for the
// spawned shell, even when an AcceptEnv pattern such as "*" matches.
switch name {
case "USER", "LOGNAME", "HOME", "SHELL", "PATH":
return false
}
if name == "TERM" || name == "LANG" || strings.HasPrefix(name, "LC_") {
return true
}
for _, pattern := range extraPatterns {
matched, _ := path.Match(pattern, name)
if matched {
return true
}
}
return false
}
func isDangerousEnv(name string) bool {
if strings.HasPrefix(name, "LD_") || strings.HasPrefix(name, "DYLD_") {
return true
}
switch name {
case "IFS", "ENV", "BASH_ENV", "SHELLOPTS", "BASHOPTS", "PS4", "GLOBIGNORE":
return true
}
return false
}
// clampWindowDimension maps a client-supplied terminal dimension into uint16 without
// the wraparound a bare cast causes (e.g. 65536 -> 0, a zero-size terminal): values
// outside the range saturate instead.
func clampWindowDimension(value int) uint16 {
if value < 0 {
return 0
}
if value > 0xffff {
return 0xffff
}
return uint16(value)
}
func (s *Server) allowLocalForward(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowLocalPortForwarding
}
func (s *Server) allowReverseForward(ctx gliderssh.Context, bindHost string, bindPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowRemotePortForwarding
}
func (s *Server) allowLocalUnixForward(ctx gliderssh.Context, socketPath string) (net.Conn, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowLocalPortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
}
return gliderssh.NewLocalUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) allowReverseUnixForward(ctx gliderssh.Context, socketPath string) (net.Listener, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowRemotePortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
BindUnlink: true,
}
return gliderssh.NewReverseUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) OnReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *tsDNS.Config) {
localBackend := s.tsnetServer.ExportLocalBackend()
netMap := localBackend.NetMap()
if netMap == nil || netMap.SSHPolicy == nil {
return
}
s.access.Lock()
connsToCheck := make([]*activeSession, 0, len(s.activeConns))
for active := range s.activeConns {
connsToCheck = append(connsToCheck, active)
}
s.access.Unlock()
for _, active := range connsToCheck {
connInfo := active.info
newConnInfo, err := s.evaluatePolicy(netMap.SSHPolicy, connInfo.sshUser, connInfo.node, connInfo.userProfile, connInfo.srcIP)
// A HoldAndDelegate rule re-evaluates to an action with Accept=false, so a
// session granted via delegation must not be revoked just because Accept is
// not set on the raw rule.
if err == nil && !newConnInfo.action.Reject && (newConnInfo.action.Accept || newConnInfo.action.HoldAndDelegate != "") && newConnInfo.localUser == connInfo.localUser {
continue
}
s.logger.Info("revoking SSH access for ", connInfo.userProfile.LoginName)
active.cancel()
}
}