~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
783 lines · 18.8 KB Raw History
package sshtransport

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"encoding/pem"
	"errors"
	"fmt"
	"log/slog"
	"maps"
	"net"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"sync"
	"time"

	sshcontract "github.com/johnandersen777/socialweb-computer-kcp/internal/contract/ssh"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
	"golang.org/x/crypto/ssh"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/supervise"
)

const (
	NoKeyCouldMatchExtension = "sshtransport.socialweb.computer/no-key-could-match"

	PermissionDID = "sshtransport.socialweb.computer/did"

	PermissionHandle = "sshtransport.socialweb.computer/handle"

	PermissionPDS = "sshtransport.socialweb.computer/pds"

	DefaultMaxConnections = 10000

	DefaultMaxSessions = 1000

	DefaultSessionsPerAccount = 1000

	DefaultMaxAuthAttempts = 6

	DefaultAuthTimeout = 120 * time.Second

	DefaultAuthorizationLookupTimeout = 30 * time.Second

	DefaultKeepAliveInterval = 15 * time.Second

	DefaultKeepAliveMisses = 3

	DefaultShutdownGrace = 5 * time.Second
)

var (
	allowedKeyAlgorithms = []string{
		"ssh-ed25519",
		"ecdsa-sha2-nistp256",
		"ecdsa-sha2-nistp384",
		"ecdsa-sha2-nistp521",
		"sk-ssh-ed25519@openssh.com",
		"sk-ecdsa-sha2-nistp256@openssh.com",
		"rsa-sha2-256",
		"rsa-sha2-512",
		"ssh-rsa",
	}

	allowedSignatureFormats = map[string]bool{
		"ssh-ed25519":                        true,
		"ecdsa-sha2-nistp256":                true,
		"ecdsa-sha2-nistp384":                true,
		"ecdsa-sha2-nistp521":                true,
		"sk-ssh-ed25519@openssh.com":         true,
		"sk-ecdsa-sha2-nistp256@openssh.com": true,
		"rsa-sha2-256":                       true,
		"rsa-sha2-512":                       true,
	}

	newlinePattern = regexp.MustCompile(`\r?\n`)
)

type PTYSize = sshcontract.PTYSize

type CommandIO = sshcontract.CommandIO

type ComputeCommandRunner interface {
	Run(ctx context.Context, account AuthorizedAccount, command string, env map[string]string, io CommandIO) error
}

type SessionRunnerShutdowner interface {
	Shutdown(ctx context.Context) error
}

type Config struct {
	Port int

	Hostname string

	HostKeyPath string

	Banner string

	NoKeyCouldMatchMessage string

	MaxConnections int

	MaxSessions int

	SessionsPerAccount int

	MaxAuthAttempts int

	AuthTimeout time.Duration

	SessionMax time.Duration
}

type ServerOptions struct {
	Config Config

	Authorizer KeyAuthorizer

	Runner ComputeCommandRunner

	DefaultCommand string

	Log *slog.Logger
}

type Server struct {
	opts Options

	sshConfig *ssh.ServerConfig

	log *slog.Logger

	mu sync.Mutex

	conns map[net.Conn]struct{}

	connWG sync.WaitGroup

	running int

	perAccount map[string]int
}

type Options = ServerOptions

var _ sshcontract.Server = (*Server)(nil)

func NewServer(opts ServerOptions) (*Server, error) {
	if opts.Authorizer == nil {
		return nil, errors.New("sshtransport: SSH server has no key authorizer")
	}
	if opts.Runner == nil {
		return nil, errors.New("sshtransport: SSH server has no command runner")
	}
	if opts.Config.HostKeyPath == "" {
		return nil, errors.New("sshtransport: SSH server has no host key path")
	}
	if opts.Log == nil {
		opts.Log = slog.Default()
	}
	opts.Config = configWithDefaults(opts.Config)
	signer, err := loadOrCreateHostKey(opts.Config.HostKeyPath, opts.Log)
	if err != nil {
		return nil, err
	}
	s := &Server{
		opts:       opts,
		log:        opts.Log,
		conns:      map[net.Conn]struct{}{},
		perAccount: map[string]int{},
	}
	cfg := &ssh.ServerConfig{
		MaxAuthTries:            opts.Config.MaxAuthAttempts,
		PublicKeyAuthAlgorithms: allowedKeyAlgorithms,
		PublicKeyCallback: func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
			return &ssh.Permissions{}, nil
		},
	}
	if opts.Config.Banner != "" {
		banner := opts.Config.Banner
		cfg.BannerCallback = func(ssh.ConnMetadata) string { return banner }
	}
	cfg.AddHostKey(signer)
	s.sshConfig = cfg
	return s, nil
}

func configWithDefaults(c Config) Config {
	c.MaxConnections = opt.Positive(c.MaxConnections, DefaultMaxConnections)
	c.MaxSessions = opt.Positive(c.MaxSessions, DefaultMaxSessions)
	c.SessionsPerAccount = opt.Positive(c.SessionsPerAccount, DefaultSessionsPerAccount)
	c.MaxAuthAttempts = opt.Positive(c.MaxAuthAttempts, DefaultMaxAuthAttempts)
	c.AuthTimeout = opt.Positive(c.AuthTimeout, DefaultAuthTimeout)
	return c
}

func loadOrCreateHostKey(path string, log *slog.Logger) (ssh.Signer, error) {
	raw, err := os.ReadFile(path)
	if err == nil {
		signer, err := ssh.ParsePrivateKey(raw)
		if err != nil {
			return nil, fmt.Errorf("%s exists but is not a usable private key; refusing to start with a different identity: %w", path, err)
		}
		if st, err := os.Stat(path); err == nil && st.Mode().Perm()&0o077 != 0 {
			if err := os.Chmod(path, 0o600); err != nil {
				log.Warn("host_key_permissions_unrepaired", "path", path, "err", err)
			} else {
				log.Info("host_key_permissions_repaired", "path", path)
			}
		}
		return signer, nil
	}
	if !errors.Is(err, os.ErrNotExist) {
		return nil, err
	}
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, err
	}
	block, err := ssh.MarshalPrivateKey(priv, "")
	if err != nil {
		return nil, err
	}
	dir := filepath.Dir(path)
	if dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o700); err != nil {
			return nil, err
		}
	}
	file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
	if err != nil {
		return nil, err
	}
	if _, err := file.Write(pem.EncodeToMemory(block)); err != nil {
		file.Close()
		return nil, err
	}
	if err := file.Close(); err != nil {
		return nil, err
	}
	log.Info("host_key_generated", "path", path)
	return ssh.NewSignerFromKey(priv)
}

func (s *Server) verifiedPublicKey(ctx context.Context, conn ssh.ConnMetadata, key ssh.PublicKey, permissions *ssh.Permissions, signatureAlgorithm string) (*ssh.Permissions, error) {
	if !allowedSignatureFormats[signatureAlgorithm] {
		s.log.Warn("auth_rejected_algo", "username", conn.User(), "algo", signatureAlgorithm)
		return nil, fmt.Errorf("sshtransport: signature algorithm %q is not accepted", signatureAlgorithm)
	}
	presented := PresentedKey{Algo: key.Type(), Key: base64.StdEncoding.EncodeToString(key.Marshal())}
	ctx, cancel := context.WithTimeout(ctx, DefaultAuthorizationLookupTimeout)
	defer cancel()
	account, err := s.opts.Authorizer.Authorize(ctx, conn.User(), presented)
	if err != nil {
		var noKey *NoKeyCouldMatchError
		if errors.As(err, &noKey) {
			s.log.Info("auth_accepted_no_key_could_match", "username", conn.User(), "reason", noKey.Reason)
			return &ssh.Permissions{Extensions: map[string]string{NoKeyCouldMatchExtension: noKey.Reason}}, nil
		}
		s.log.Warn("auth_error", "username", conn.User(), "err", err)
		return nil, err
	}
	if account == nil {
		s.log.Info("auth_rejected", "username", conn.User())
		return nil, errors.New("sshtransport: the offered key is not associated with the account")
	}
	s.log.Info("auth_accepted", "username", conn.User(), "did", account.DID)
	return &ssh.Permissions{Extensions: map[string]string{
		PermissionDID:    account.DID,
		PermissionHandle: account.Handle,
		PermissionPDS:    account.PDS,
	}}, nil
}

func (s *Server) ServeConn(ctx context.Context, conn net.Conn) error {
	if conn == nil {
		return errors.New("sshtransport: no connection")
	}
	s.mu.Lock()
	if len(s.conns) >= s.opts.Config.MaxConnections {
		s.mu.Unlock()
		conn.Close()
		return errors.New("sshtransport: at the concurrent connection limit")
	}
	s.conns[conn] = struct{}{}
	s.connWG.Add(1)
	s.mu.Unlock()
	done := make(chan struct{})
	s.connWG.Go(func() {
		select {
		case <-ctx.Done():
			conn.Close()
		case <-done:
		}
	})
	s.handleConnection(ctx, conn)
	close(done)
	s.mu.Lock()
	delete(s.conns, conn)
	s.mu.Unlock()
	s.connWG.Done()
	return nil
}

func (s *Server) handleConnection(ctx context.Context, raw net.Conn) {
	if s.opts.Config.AuthTimeout > 0 {
		raw.SetDeadline(time.Now().Add(s.opts.Config.AuthTimeout))
	}
	cfg := *s.sshConfig
	cfg.VerifiedPublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey, permissions *ssh.Permissions, algo string) (*ssh.Permissions, error) {
		return s.verifiedPublicKey(ctx, c, key, permissions, algo)
	}
	conn, channels, requests, err := ssh.NewServerConn(raw, &cfg)
	if err != nil {
		raw.Close()
		s.log.Warn("connection_error", "err", err)
		return
	}
	raw.SetDeadline(time.Time{})
	defer conn.Close()
	s.connWG.Go(func() { s.keepAlive(conn) })
	account := accountFromPermissions(conn.Permissions)
	noKeyUsername := ""
	if conn.Permissions != nil {
		if _, ok := conn.Permissions.Extensions[NoKeyCouldMatchExtension]; ok {
			noKeyUsername = conn.User()
		}
	}
	s.connWG.Go(func() { ssh.DiscardRequests(requests) })
	for newChannel := range channels {
		if newChannel.ChannelType() != "session" {
			newChannel.Reject(ssh.UnknownChannelType, "only session channels are served")
			continue
		}
		channel, channelRequests, err := newChannel.Accept()
		if err != nil {
			s.log.Warn("channel_accept_failed", "err", err)
			continue
		}
		s.connWG.Go(func() { s.serveSession(ctx, channel, channelRequests, account, conn.User(), noKeyUsername) })
	}
}

func (s *Server) keepAlive(conn *ssh.ServerConn) {
	gone := make(chan struct{})
	go func() {
		_ = conn.Wait()
		close(gone)
	}()
	ticker := time.NewTicker(DefaultKeepAliveInterval)
	defer ticker.Stop()
	misses := 0
	for {
		select {
		case <-gone:
			return
		case <-ticker.C:
		}
		replied := make(chan error, 1)
		go func() {
			_, _, err := conn.SendRequest("keepalive@openssh.com", true, nil)
			replied <- err
		}()
		select {
		case <-gone:
			return
		case err := <-replied:
			if err == nil {
				misses = 0
				continue
			}
		case <-time.After(DefaultKeepAliveInterval):
		}
		if misses++; misses >= DefaultKeepAliveMisses {
			s.log.Info("connection_keepalive_lost", "user", conn.User(), "misses", misses)
			_ = conn.Close()
			return
		}
	}
}

func accountFromPermissions(permissions *ssh.Permissions) *AuthorizedAccount {
	if permissions == nil || permissions.Extensions == nil {
		return nil
	}
	did := permissions.Extensions[PermissionDID]
	if did == "" {
		return nil
	}
	return &AuthorizedAccount{
		DID:    did,
		Handle: permissions.Extensions[PermissionHandle],
		PDS:    permissions.Extensions[PermissionPDS],
	}
}

type ptyRequest struct {
	Term          string
	Cols, Rows    uint32
	Width, Height uint32
	Modes         string
}

type windowChangeRequest struct {
	Cols, Rows    uint32
	Width, Height uint32
}

type envRequest struct {
	Name  string
	Value string
}

type execRequest struct {
	Command string
}

func (s *Server) serveSession(ctx context.Context, channel ssh.Channel, requests <-chan *ssh.Request, account *AuthorizedAccount, username, noKeyUsername string) {
	defer channel.Close()
	ctx, closed := context.WithCancel(ctx)
	defer closed()
	io := newChannelIO(channel, &s.connWG)
	env := map[string]string{}
	envBytes := 0
	started := false
	for req := range requests {
		switch req.Type {
		case "pty-req":
			var request ptyRequest
			if err := ssh.Unmarshal(req.Payload, &request); err != nil {
				req.Reply(false, nil)
				continue
			}
			io.setPTY(&PTYSize{Cols: int(request.Cols), Rows: int(request.Rows)})
			req.Reply(true, nil)
		case "window-change":
			var request windowChangeRequest
			if err := ssh.Unmarshal(req.Payload, &request); err == nil && io.PTY() != nil {
				io.setSize(int(request.Cols), int(request.Rows))
			}
			req.Reply(false, nil)
		case "env":
			var request envRequest
			if err := ssh.Unmarshal(req.Payload, &request); err != nil {
				req.Reply(false, nil)
				continue
			}
			if len(env) < 64 && envBytes+len(request.Name)+len(request.Value) <= 8192 {
				env[request.Name] = request.Value
				envBytes += len(request.Name) + len(request.Value)
			}
			req.Reply(true, nil)
		case "exec":
			if started {
				req.Reply(false, nil)
				continue
			}
			var request execRequest
			if err := ssh.Unmarshal(req.Payload, &request); err != nil {
				req.Reply(false, nil)
				continue
			}
			started = true
			req.Reply(true, nil)
			sessionEnv := copyEnv(env)
			s.connWG.Go(func() { s.startSession(ctx, io, sessionEnv, account, username, noKeyUsername, request.Command) })
		case "shell":
			if started {
				req.Reply(false, nil)
				continue
			}
			started = true
			req.Reply(true, nil)
			sessionEnv := copyEnv(env)
			s.connWG.Go(func() { s.startSession(ctx, io, sessionEnv, account, username, noKeyUsername, s.opts.DefaultCommand) })
		default:
			req.Reply(false, nil)
		}
	}
	io.markClosed()
	if started && !io.exited() {
		s.log.Info("session_channel_closed", "username", username, "reason", "the client closed the channel or the connection dropped; the command ends")
	}
	closed()
}

func copyEnv(env map[string]string) map[string]string {
	out := make(map[string]string, len(env))
	maps.Copy(out, env)
	return out
}

func (s *Server) startSession(ctx context.Context, io *channelIO, env map[string]string, account *AuthorizedAccount, username, noKeyUsername, command string) {
	if account == nil {
		explanation := "authentication required\n"
		if noKeyUsername != "" {
			explanation = s.opts.Config.NoKeyCouldMatchMessage
			if explanation == "" {
				explanation = defaultNoKeyMessage(noKeyUsername)
			}
		}
		s.log.Info("session_refused_no_key_could_match", "username", username)
		_ = io.WriteErr([]byte(explanation))
		io.Exit(1)
		return
	}
	if refusal := s.acquire(account.DID); refusal != "" {
		s.log.Info("session_refused", "did", account.DID, "reason", refusal)
		_ = io.WriteErr([]byte(refusal + "\n"))
		io.Exit(1)
		return
	}
	defer s.release(account.DID)
	sessionID := randomHex(16)
	remaining := s.opts.Config.SessionMax
	s.log.Info("session_started", "sessionId", sessionID, "did", account.DID,
		"remainingSec", int(remaining/time.Second), "commandChars", len(command))
	sctx, cancel := context.WithCancel(ctx)
	defer cancel()
	var timer *time.Timer
	if remaining > 0 {
		timer = time.AfterFunc(remaining, func() {
			s.log.Info("session_capped", "sessionId", sessionID, "did", account.DID,
				"remainingSec", int(remaining/time.Second))
			cancel()
			io.Exit(1)
		})
		defer timer.Stop()
	}
	err := s.opts.Runner.Run(sctx, *account, command, env, io)
	if err != nil {
		if io.exited() {
			s.log.Warn("post_exit_error", "did", account.DID, "err", err)
			return
		}
		s.log.Warn("provisioning_failed", "did", account.DID, "err", err)
		_ = io.WriteErr([]byte("provisioning failed: " + err.Error() + "\n"))
		io.Exit(1)
	}
}

func (s *Server) acquire(did string) string {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.running >= s.opts.Config.MaxSessions {
		return fmt.Sprintf("server is at its %d concurrent session limit", s.opts.Config.MaxSessions)
	}
	held := s.perAccount[did]
	if held >= s.opts.Config.SessionsPerAccount {
		return fmt.Sprintf("account already has %d sessions running", held)
	}
	s.running++
	s.perAccount[did] = held + 1
	return ""
}

func (s *Server) release(did string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.running > 0 {
		s.running--
	}
	held := s.perAccount[did] - 1
	if held <= 0 {
		delete(s.perAccount, did)
	} else {
		s.perAccount[did] = held
	}
}

func (s *Server) closeConnections() {
	s.mu.Lock()
	conns := make([]net.Conn, 0, len(s.conns))
	for conn := range s.conns {
		conns = append(conns, conn)
	}
	s.mu.Unlock()
	for _, conn := range conns {
		conn.Close()
	}
}

func (s *Server) Shutdown(ctx context.Context) error {
	if runner, ok := s.opts.Runner.(SessionRunnerShutdowner); ok {
		grace, stop := context.WithTimeout(ctx, DefaultShutdownGrace)
		_ = runner.Shutdown(grace)
		stop()
	}
	s.closeConnections()
	return supervise.Join(ctx, s.connWG.Wait)
}

func defaultNoKeyMessage(username string) string {
	return "\n" +
		"socialweb-computer-ssh\n" +
		"\n" +
		"  No SSH key registered for \"" + username + "\" can sign in here.\n" +
		"\n" +
		"  If that is your handle, register an SSH key against the account first.\n" +
		"  If it is not, use your handle as the username -- the account, not just the\n" +
		"  key, is what this door authenticates against.\n" +
		"\n"
}

func randomHex(n int) string {
	raw := make([]byte, n)
	if _, err := rand.Read(raw); err != nil {
		return strconv.FormatInt(time.Now().UnixNano(), 16)
	}
	return hex.EncodeToString(raw)
}

type channelIO struct {
	channel ssh.Channel

	reader *sync.WaitGroup

	mu sync.Mutex

	deliverMu sync.Mutex

	pty *PTYSize

	dataHandler func([]byte)

	closeHandlers []func()

	pending [][]byte

	pendingBytes int

	readerStarted bool

	closed bool

	exitOnce sync.Once

	exitedFlag bool
}

func newChannelIO(channel ssh.Channel, reader *sync.WaitGroup) *channelIO {
	return &channelIO{channel: channel, reader: reader}
}

func (c *channelIO) Write(chunk []byte) error {
	_, err := c.channel.Write(forClientTerminal(c.PTY(), chunk))
	return err
}

func (c *channelIO) WriteErr(chunk []byte) error {
	_, err := c.channel.Stderr().Write(forClientTerminal(c.PTY(), chunk))
	return err
}

func (c *channelIO) OnData(handler func(chunk []byte)) {
	c.deliverMu.Lock()
	c.mu.Lock()
	c.dataHandler = handler
	pending := c.pending
	c.pending = nil
	c.pendingBytes = 0
	if !c.readerStarted {
		c.readerStarted = true
		c.reader.Go(c.readLoop)
	}
	c.mu.Unlock()
	for _, chunk := range pending {
		handler(chunk)
	}
	c.deliverMu.Unlock()
}

func (c *channelIO) readLoop() {
	buf := make([]byte, 32*1024)
	for {
		n, err := c.channel.Read(buf)
		if n > 0 {
			chunk := append([]byte(nil), buf[:n]...)
			c.mu.Lock()
			handler := c.dataHandler
			if handler == nil {
				if c.pendingBytes+len(chunk) <= 1<<20 {
					c.pending = append(c.pending, chunk)
					c.pendingBytes += len(chunk)
				}
			}
			c.mu.Unlock()
			if handler != nil {
				c.deliverMu.Lock()
				handler(chunk)
				c.deliverMu.Unlock()
			}
		}
		if err != nil {
			c.markClosed()
			return
		}
	}
}

func (c *channelIO) OnClose(handler func()) {
	c.mu.Lock()
	if c.closed {
		c.mu.Unlock()
		handler()
		return
	}
	c.closeHandlers = append(c.closeHandlers, handler)
	c.mu.Unlock()
}

func (c *channelIO) markClosed() {
	c.mu.Lock()
	if c.closed {
		c.mu.Unlock()
		return
	}
	c.closed = true
	handlers := c.closeHandlers
	c.closeHandlers = nil
	c.mu.Unlock()
	for _, handler := range handlers {
		handler()
	}
}

func (c *channelIO) Exit(code int) {
	c.exitOnce.Do(func() {
		c.mu.Lock()
		c.exitedFlag = true
		c.mu.Unlock()
		_, _ = c.channel.SendRequest("exit-status", false, ssh.Marshal(struct {
			Status uint32
		}{uint32(code)}))
		_ = c.channel.Close()
	})
}

func (c *channelIO) exited() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.exitedFlag
}

func (c *channelIO) setPTY(size *PTYSize) {
	c.mu.Lock()
	c.pty = size
	c.mu.Unlock()
}

func (c *channelIO) setSize(cols, rows int) {
	c.mu.Lock()
	if c.pty == nil {
		c.pty = &PTYSize{}
	}
	c.pty.Cols = cols
	c.pty.Rows = rows
	c.mu.Unlock()
}

func (c *channelIO) PTY() *PTYSize {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.pty == nil {
		return nil
	}
	copied := *c.pty
	return &copied
}

func forClientTerminal(pty *PTYSize, chunk []byte) []byte {
	if pty == nil || !bytes.Contains(chunk, []byte("\n")) {
		return chunk
	}
	return newlinePattern.ReplaceAll(chunk, []byte("\r\n"))
}