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

+87
View File
@@ -0,0 +1,87 @@
//go:build !windows
package sshd
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"github.com/creack/pty"
"golang.org/x/crypto/ssh"
)
const (
ptySupported = true
runSupported = true
)
// run starts the user's shell (or, for exec, runs cmd through it) and returns its exit code.
// With a pty request it gets a terminal, otherwise plain pipes.
func run(ch ssh.Channel, cmd string, p *ptyReq, win <-chan [2]uint32) int {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
var c *exec.Cmd
if cmd == "" {
c = exec.Command(shell, "-l")
} else {
c = exec.Command(shell, "-c", cmd)
}
c.Env = os.Environ()
if p != nil {
term := p.term
if term == "" {
term = "xterm"
}
c.Env = append(c.Env, "TERM="+term)
return runPty(ch, c, p, win)
}
in, err := c.StdinPipe()
if err != nil {
fmt.Fprintf(ch.Stderr(), "bamboo: %v\r\n", err)
return 255
}
go func() { io.Copy(in, ch); in.Close() }()
c.Stdout = ch
c.Stderr = ch.Stderr()
return exitCode(c.Run(), c)
}
func runPty(ch ssh.Channel, c *exec.Cmd, p *ptyReq, win <-chan [2]uint32) int {
f, err := pty.StartWithSize(c, &pty.Winsize{Cols: uint16(p.cols), Rows: uint16(p.rows)})
if err != nil {
fmt.Fprintf(ch.Stderr(), "bamboo: %v\r\n", err)
return 255
}
defer f.Close()
done := make(chan struct{})
defer close(done)
go func() {
for {
select {
case w := <-win:
pty.Setsize(f, &pty.Winsize{Cols: uint16(w[0]), Rows: uint16(w[1])})
case <-done:
return
}
}
}()
go func() { io.Copy(f, ch) }()
io.Copy(ch, f) // ends with an error when the shell exits and the terminal closes
return exitCode(c.Wait(), c)
}
func exitCode(err error, c *exec.Cmd) int {
if err == nil {
return 0
}
var ee *exec.ExitError
if errors.As(err, &ee) && ee.ExitCode() >= 0 {
return ee.ExitCode()
}
return 255
}
+84
View File
@@ -0,0 +1,84 @@
//go:build !windows
package sshd
import (
"errors"
"strings"
"testing"
"golang.org/x/crypto/ssh"
)
func session(t *testing.T) (*ssh.Session, func()) {
t.Helper()
good := newSigner(t)
addr, _, _ := start(t, good)
c, err := dial(addr, ssh.PublicKeys(good))
if err != nil {
t.Fatal(err)
}
s, err := c.NewSession()
if err != nil {
t.Fatal(err)
}
return s, func() { s.Close(); c.Close() }
}
func TestExec(t *testing.T) {
s, done := session(t)
defer done()
out, err := s.Output("echo hello; echo oops >&2")
if err != nil || string(out) != "hello\n" {
t.Fatalf("got %q, %v", out, err)
}
}
func TestExecExitStatus(t *testing.T) {
s, done := session(t)
defer done()
err := s.Run("exit 3")
var ee *ssh.ExitError
if !errors.As(err, &ee) || ee.ExitStatus() != 3 {
t.Fatalf("want exit status 3, got %v", err)
}
}
func TestExecReadsStdin(t *testing.T) {
s, done := session(t)
defer done()
s.Stdin = strings.NewReader("from the client\n")
out, err := s.Output("cat")
if err != nil || string(out) != "from the client\n" {
t.Fatalf("got %q, %v", out, err)
}
}
func TestPtyExec(t *testing.T) {
s, done := session(t)
defer done()
if err := s.RequestPty("xterm", 24, 80, ssh.TerminalModes{ssh.ECHO: 0}); err != nil {
t.Fatal(err)
}
out, err := s.CombinedOutput(`echo term=$TERM; test -t 0 && echo tty`)
if err != nil || !strings.Contains(string(out), "term=xterm") || !strings.Contains(string(out), "tty") {
t.Fatalf("got %q, %v", out, err)
}
}
func TestShell(t *testing.T) {
s, done := session(t)
defer done()
s.Stdin = strings.NewReader("echo from-shell\nexit 4\n")
var out strings.Builder
s.Stdout = &out
err := s.Shell()
if err != nil {
t.Fatal(err)
}
err = s.Wait()
var ee *ssh.ExitError
if !errors.As(err, &ee) || ee.ExitStatus() != 4 || !strings.Contains(out.String(), "from-shell") {
t.Fatalf("got %q, %v", out.String(), err)
}
}
+12
View File
@@ -0,0 +1,12 @@
package sshd
import "golang.org/x/crypto/ssh"
// Shells need a pseudo-terminal, which Windows has no simple equivalent of here, so the server
// signs people in but refuses to start a session. main reports this before it starts the server.
const (
ptySupported = false
runSupported = false
)
func run(ssh.Channel, string, *ptyReq, <-chan [2]uint32) int { return 1 }
+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)
}
+150
View File
@@ -0,0 +1,150 @@
package sshd
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
func newSigner(t *testing.T) ssh.Signer {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
s, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
return s
}
func authLine(s ssh.Signer) string { return string(ssh.MarshalAuthorizedKey(s.PublicKey())) }
// start runs a server that accepts the given signers and returns its address.
func start(t *testing.T, allowed ...ssh.Signer) (addr, keysFile string, srv *Server) {
t.Helper()
dir := t.TempDir()
keysFile = filepath.Join(dir, "authorized_keys")
var lines string
for _, s := range allowed {
lines += authLine(s)
}
if err := os.WriteFile(keysFile, []byte("# who may sign in\n"+lines), 0o600); err != nil {
t.Fatal(err)
}
srv, err := New(Config{AuthorizedKeys: keysFile, HostKey: filepath.Join(dir, "host_key"), Logf: t.Logf})
if err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { srv.Serve(ctx, ln); close(done) }()
t.Cleanup(func() { cancel(); <-done })
return ln.Addr().String(), keysFile, srv
}
func dial(addr string, auth ...ssh.AuthMethod) (*ssh.Client, error) {
return ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "tester", Auth: auth, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second})
}
func TestNewNeedsKeys(t *testing.T) {
dir := t.TempDir()
cfg := Config{AuthorizedKeys: filepath.Join(dir, "authorized_keys"), HostKey: filepath.Join(dir, "host_key")}
if _, err := New(cfg); err == nil {
t.Fatal("a missing authorized_keys file should be an error")
}
os.WriteFile(cfg.AuthorizedKeys, []byte("# nothing yet\n\n"), 0o600)
if _, err := New(cfg); err == nil {
t.Fatal("an authorized_keys file without keys should be an error")
}
}
func TestOptionsLinesAreSkipped(t *testing.T) {
a, b := newSigner(t), newSigner(t)
path := filepath.Join(t.TempDir(), "authorized_keys")
os.WriteFile(path, []byte(`command="/bin/false" `+authLine(a)+authLine(b)), 0o600)
keys, err := loadAuthorizedKeys(path)
if err != nil || len(keys) != 1 || ssh.FingerprintSHA256(keys[0]) != ssh.FingerprintSHA256(b.PublicKey()) {
t.Fatalf("only the key without options should load, got %d keys, err %v", len(keys), err)
}
}
func TestHostKeyIsStable(t *testing.T) {
dir := t.TempDir()
keys := filepath.Join(dir, "authorized_keys")
os.WriteFile(keys, []byte(authLine(newSigner(t))), 0o600)
cfg := Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "sub", "host_key")}
a, err := New(cfg)
if err != nil {
t.Fatal(err)
}
b, err := New(cfg)
if err != nil {
t.Fatal(err)
}
if a.Fingerprint() != b.Fingerprint() {
t.Fatal("the host key changed between starts")
}
}
func TestKeyAuth(t *testing.T) {
good, bad := newSigner(t), newSigner(t)
addr, _, _ := start(t, good)
c, err := dial(addr, ssh.PublicKeys(good))
if err != nil {
t.Fatalf("authorized key refused: %v", err)
}
c.Close()
if _, err := dial(addr, ssh.PublicKeys(bad)); err == nil {
t.Fatal("unknown key was accepted")
}
if _, err := dial(addr, ssh.Password("hunter2")); err == nil {
t.Fatal("a password was accepted")
}
if _, err := dial(addr, ssh.KeyboardInteractive(func(string, string, []string, []bool) ([]string, error) { return nil, nil })); err == nil {
t.Fatal("keyboard-interactive was accepted")
}
}
func TestKeysCanChangeWhileRunning(t *testing.T) {
first, second := newSigner(t), newSigner(t)
addr, keysFile, _ := start(t, first)
if _, err := dial(addr, ssh.PublicKeys(second)); err == nil {
t.Fatal("second key accepted before it was added")
}
f, _ := os.OpenFile(keysFile, os.O_APPEND|os.O_WRONLY, 0o600)
f.WriteString(authLine(second))
f.Close()
c, err := dial(addr, ssh.PublicKeys(second))
if err != nil {
t.Fatalf("key added to the file was not picked up: %v", err)
}
c.Close()
}
func TestNoForwarding(t *testing.T) {
good := newSigner(t)
addr, _, _ := start(t, good)
c, err := dial(addr, ssh.PublicKeys(good))
if err != nil {
t.Fatal(err)
}
defer c.Close()
if conn, err := c.Dial("tcp", "127.0.0.1:1"); err == nil {
conn.Close()
t.Fatal("port forwarding was allowed")
}
}