// Package sshd is a small SSH server for Bamboo, so a machine behind NAT can be reached with an // ordinary ssh client through a tunnelled port, without installing or configuring sshd. It is // opt-in and deliberately narrow: public keys only (listed in an authorized_keys file), session // channels only (a shell or one command, no port forwarding, no agent forwarding, no SFTP), and // it listens on the loopback address unless told otherwise. Sessions run as the user Bamboo runs as. package sshd import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/pem" "errors" "fmt" "net" "os" "path/filepath" "sync" "time" "golang.org/x/crypto/ssh" ) const ( // DefaultListen is loopback only: a Hammerhead port that targets this site reaches it // through Bamboo, and nothing else on the network can. DefaultListen = "127.0.0.1:2222" maxConns = 16 handshakeTimeout = 20 * time.Second ) // Supported says whether this platform can run shells (it cannot on Windows). const Supported = runSupported // Config says where the server listens and which keys may sign in. type Config struct { Listen string // host:port, default DefaultListen AuthorizedKeys string // path of an authorized_keys file; read on every sign-in, so edits apply at once HostKey string // path of the server's ed25519 key; created on first start Logf func(format string, args ...any) } // Server accepts SSH connections. type Server struct { cfg Config conf *ssh.ServerConfig host ssh.Signer sem chan struct{} } // New loads (or creates) the host key and checks that at least one authorized key exists, so // the server never starts in a state where nobody, or everybody, could sign in. func New(cfg Config) (*Server, error) { if cfg.Listen == "" { cfg.Listen = DefaultListen } if cfg.Logf == nil { cfg.Logf = func(string, ...any) {} } if cfg.AuthorizedKeys == "" { return nil, errors.New("no authorized_keys file configured") } keys, err := loadAuthorizedKeys(cfg.AuthorizedKeys) if err != nil { return nil, err } if len(keys) == 0 { return nil, fmt.Errorf("%s has no public keys: add the keys that may sign in, one per line", cfg.AuthorizedKeys) } signer, err := loadOrCreateHostKey(cfg.HostKey) if err != nil { return nil, err } s := &Server{cfg: cfg, host: signer, sem: make(chan struct{}, maxConns)} s.conf = &ssh.ServerConfig{ MaxAuthTries: 3, // Only public keys. There is no password or keyboard-interactive callback at all. PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { keys, err := loadAuthorizedKeys(s.cfg.AuthorizedKeys) if err != nil { s.cfg.Logf("ssh: %v", err) return nil, errors.New("no access") } for _, k := range keys { if bytes.Equal(k.Marshal(), key.Marshal()) { return &ssh.Permissions{Extensions: map[string]string{"fp": ssh.FingerprintSHA256(key)}}, nil } } return nil, errors.New("key not authorized") }, } s.conf.AddHostKey(signer) return s, nil } // Fingerprint is the server's host key fingerprint, for the log so it can be checked on first use. func (s *Server) Fingerprint() string { return ssh.FingerprintSHA256(s.host.PublicKey()) } // Run listens until ctx is cancelled. func (s *Server) Run(ctx context.Context) error { ln, err := net.Listen("tcp", s.cfg.Listen) if err != nil { return err } return s.Serve(ctx, ln) } // Serve accepts connections on ln until ctx is cancelled. func (s *Server) Serve(ctx context.Context, ln net.Listener) error { go func() { <-ctx.Done(); ln.Close() }() var wg sync.WaitGroup defer wg.Wait() for { c, err := ln.Accept() if err != nil { if ctx.Err() != nil { return nil } var ne net.Error if errors.As(err, &ne) && ne.Timeout() { continue } return err } select { case s.sem <- struct{}{}: default: c.Close() // too many sessions already continue } wg.Add(1) go func() { defer wg.Done() defer func() { <-s.sem }() s.handle(ctx, c) }() } } func (s *Server) handle(ctx context.Context, nc net.Conn) { defer nc.Close() nc.SetDeadline(time.Now().Add(handshakeTimeout)) conn, chans, reqs, err := ssh.NewServerConn(nc, s.conf) if err != nil { s.cfg.Logf("ssh: sign-in from %s failed: %v", nc.RemoteAddr(), err) return } nc.SetDeadline(time.Time{}) defer conn.Close() s.cfg.Logf("ssh: %s signed in as %s (%s)", conn.User(), nc.RemoteAddr(), conn.Permissions.Extensions["fp"]) go func() { <-ctx.Done(); conn.Close() }() go ssh.DiscardRequests(reqs) var wg sync.WaitGroup for ch := range chans { if ch.ChannelType() != "session" { ch.Reject(ssh.UnknownChannelType, "only sessions are allowed") continue } c, creqs, err := ch.Accept() if err != nil { continue } wg.Add(1) go func() { defer wg.Done() s.session(c, creqs) }() } wg.Wait() } type ptyReq struct { term string cols, rows uint32 } // session answers a session channel's requests: pty-req, window-change, env (ignored), shell and exec. func (s *Server) session(ch ssh.Channel, reqs <-chan *ssh.Request) { defer ch.Close() var pty *ptyReq var win chan [2]uint32 for req := range reqs { switch req.Type { case "pty-req": var p struct { Term string Cols, Rows, Wpx, Hpx uint32 Modes string } if ssh.Unmarshal(req.Payload, &p) != nil || !ptySupported { req.Reply(false, nil) continue } pty = &ptyReq{term: p.Term, cols: p.Cols, rows: p.Rows} win = make(chan [2]uint32, 4) req.Reply(true, nil) case "window-change": var w struct{ Cols, Rows, Wpx, Hpx uint32 } if ssh.Unmarshal(req.Payload, &w) == nil && win != nil { select { case win <- [2]uint32{w.Cols, w.Rows}: default: } } req.Reply(true, nil) case "env": req.Reply(true, nil) // accepted and ignored: the client cannot change the environment case "shell", "exec": var cmd string if req.Type == "exec" { var e struct{ Command string } if ssh.Unmarshal(req.Payload, &e) != nil { req.Reply(false, nil) return } cmd = e.Command } if !runSupported { req.Reply(false, nil) return } req.Reply(true, nil) code := run(ch, cmd, pty, win) ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)})) return default: req.Reply(false, nil) // subsystem (sftp), agent and X11 forwarding, signals: not offered } } } // loadAuthorizedKeys reads the public keys from an authorized_keys file. Options before a key // (command=, from=, ...) are not supported, so a line that has them is skipped rather than being // honoured too loosely. func loadAuthorizedKeys(path string) ([]ssh.PublicKey, error) { b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("authorized keys: %w", err) } var keys []ssh.PublicKey for len(b) > 0 { key, _, options, rest, err := ssh.ParseAuthorizedKey(b) if err != nil { break // comments and blank lines are skipped by the parser; anything else ends the list } b = rest if len(options) > 0 { continue } keys = append(keys, key) } return keys, nil } // loadOrCreateHostKey keeps the server's identity across restarts, so clients are not warned // that it changed. func loadOrCreateHostKey(path string) (ssh.Signer, error) { if path == "" { return nil, errors.New("no host key path configured") } if b, err := os.ReadFile(path); err == nil { return ssh.ParsePrivateKey(b) } else if !os.IsNotExist(err) { return nil, err } _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, err } der, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return nil, err } pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, err } if err := os.WriteFile(path, pemBytes, 0o600); err != nil { return nil, err } return ssh.ParsePrivateKey(pemBytes) }