Files
Bamboo/sshd/sshd.go
T
VeillaxandClaude Sonnet 5.5 1fba65fd3e
CI / test (push) Successful in 1m26s
Release / release (push) Successful in 4m36s
ssh: don't log health checks and port probes as failed sign-ins
A connection that never sends a byte is a probe (Hammerhead health-checks every
TCP resource that way), so it is no longer logged every 30 seconds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 00:32:50 -06:00

304 lines
8.3 KiB
Go

// 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"
"sync/atomic"
"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))
rc := &readCounter{Conn: nc}
conn, chans, reqs, err := ssh.NewServerConn(rc, s.conf)
if err != nil {
// A client that connects and never says anything is a port probe or a health check (Hammerhead
// checks every TCP resource this way), not a failed sign-in, so it is not worth a log line each time.
if rc.n.Load() > 0 {
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()
}
// readCounter counts the bytes a client has sent.
type readCounter struct {
net.Conn
n atomic.Int64
}
func (r *readCounter) Read(p []byte) (int, error) {
n, err := r.Conn.Read(p)
r.n.Add(int64(n))
return n, err
}
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)
}