Add -ssh: an opt-in built-in SSH server (public keys only, sessions only)

This commit is contained in:
Veillax committed 2026-10-30 21:40:27 -06:00
1 parent 2dea372f9a
commit fc3d98ac83
10 files changed
+654

No files matched your search

+285
View File
@@ -0,0 +1,285 @@
// 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)
}