//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"
	"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"
	"github.com/sagernet/sing/service/filemanager"
	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(ctx context.Context, 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(ctx)
	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 := filemanager.ReadFile(s.serverCtx, 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 := filemanager.ReadFile(s.serverCtx, 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 = filemanager.MkdirAll(s.serverCtx, dir, 0o700)
	if err != nil {
		return nil, err
	}
	err = filemanager.WriteFile(s.serverCtx, 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.NetMapNoPeers()
	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.NetMapNoPeers()
	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(s.platformInterface, 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, err := newAgentListener(localUser)
		if err == nil {
			defer agentListener.Close()
			agentSocketPath = agentListener.Addr().String()
			go gliderssh.ForwardAgentConnections(agentListener, session)
		} else {
			s.logger.Warn("create agent listener: ", err)
		}
	}
	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, widthPixels, heightPixels uint16
	if isPty {
		term = ptyReq.Term
		rows = clampWindowDimension(ptyReq.Window.Height)
		cols = clampWindowDimension(ptyReq.Window.Width)
		widthPixels = clampWindowDimension(ptyReq.Window.WidthPixels)
		heightPixels = clampWindowDimension(ptyReq.Window.HeightPixels)
	}
	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,
		WidthPixels:  widthPixels,
		HeightPixels: heightPixels,
	})
	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),
						clampWindowDimension(win.WidthPixels),
						clampWindowDimension(win.HeightPixels),
					)
				}
				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(s.platformInterface, 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, localUser.Shell),
		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,
	)
	defaultPath := defaultPathEnv(s.platformInterface)
	if defaultPath != "" {
		env = append(env, "PATH="+defaultPath)
	}
	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().NetMapNoPeers()
		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.NetMapNoPeers()
	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()
	}
}
