5 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 1fba65fd3e ssh: don't log health checks and port probes as failed sign-ins
CI / test (push) Successful in 1m26s
Release / release (push) Successful in 4m36s
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
Veillax 6142ae6262 Merge remote-tracking branch 'origin/main'
CI / test (push) Successful in 3m34s
Release / release (push) Successful in 4m29s
2026-10-30 21:47:00 -06:00
Veillax 5edb1ed2b9 Tidy go.mod for the SSH server dependencies 2026-10-30 21:40:42 -06:00
Veillax fc3d98ac83 Add -ssh: an opt-in built-in SSH server (public keys only, sessions only) 2026-10-30 21:40:27 -06:00
Veillax 228d376dae Update README.md
CI / test (push) Successful in 1m22s
2026-10-30 21:39:06 -06:00
10 changed files with 728 additions and 4 deletions

No files matched your search

+5
View File
@@ -14,6 +14,11 @@ BAMBOO_TOKEN=
#BAMBOO_DISCOVER_PORTS=22,80,443,8000-8010
#BAMBOO_DISCOVER_RATE=50
# Run a built-in SSH server (public keys only). The keys that may sign in go in authorized_keys in the data folder.
#BAMBOO_SSH=1
#BAMBOO_SSH_LISTEN=127.0.0.1:2222
#BAMBOO_SSH_KEYS=
# Folder for this agent's WireGuard key.
#BAMBOO_DATA=./bamboo-data
+7 -3
View File
@@ -24,6 +24,9 @@ Bamboo only makes outbound connections (HTTPS to Hammerhead, and WireGuard UDP t
| `-discover-subnets` | `BAMBOO_DISCOVER_SUBNETS` | also scan these private subnets, e.g. `192.168.1.0/24,10.0.0.0/24` (each at most a /22) |
| `-discover-ports` | `BAMBOO_DISCOVER_PORTS` | ports to try when scanning, e.g. `22,80,443,8000-8010` (default: 19 common ones) |
| `-discover-rate` | `BAMBOO_DISCOVER_RATE` | most connection attempts per second (default 50) |
| `-ssh` | `BAMBOO_SSH=1` | run a small SSH server so this machine can be reached through a tunnelled port (Linux and macOS) |
| `-ssh-listen` | `BAMBOO_SSH_LISTEN` | address it listens on (default `127.0.0.1:2222`) |
| `-ssh-keys` | `BAMBOO_SSH_KEYS` | `authorized_keys` file listing who may sign in (default: `authorized_keys` in the data folder) |
| `-env` | | `.env` file to read (default `./.env`) |
Settings and secrets can live in a `.env` file instead of the command line; see `.env.example`. Real environment variables always win.
@@ -33,6 +36,7 @@ Settings and secrets can live in a `.env` file instead of the command line; see
- **Tunnel:** one WireGuard peer per edge, through a userspace network stack (no root, no kernel interface, no driver; works on Windows, macOS and Linux). Its private key never leaves this machine. TCP connections and native UDP datagrams are accepted from the edge and handed to your services.
- **Docker discovery:** with `-docker`, lists running containers (and their ports and networks) so you can pick one as a target in the dashboard.
- **Service discovery:** off unless you ask for it. With `-discover`, lists the TCP services listening on this machine (read from `/proc/net/tcp`, so Linux only). With `-discover-subnets`, it also tries the chosen ports on every address of those subnets. Each hit is identified (SSH, SMTP, FTP, HTTP, HTTPS, or the usual service for that port) and reported to Hammerhead as a suggested target; nothing is exposed until you pick one. It is deliberately gentle: only private, loopback or link-local subnets of at most 1024 addresses are accepted, connection attempts are capped (50 per second by default), a sweep runs every 15 minutes, and a service that stays silent gets a single TLS handshake and then one `HEAD /` request, nothing more. At most 500 services are reported. Only scan networks you are allowed to scan.
- **SSH server:** off unless you ask for it. With `-ssh`, Bamboo runs a small SSH server, so you can open a shell or run a command on this machine from anywhere through a tunnelled port, with no sshd to install. Put the public keys that may sign in in `authorized_keys` in the data folder (one per line, like OpenSSH; Bamboo will not start the server without at least one, and picks up edits without a restart). Then, in Hammerhead, add a TCP port whose target is this site at `127.0.0.1:2222`, and connect with `ssh -p <port> user@<edge address>`. The user name is ignored: sessions run as the user Bamboo runs as, so a Bamboo running as root gives a root shell, and the keys should be treated that way. Public keys only (no passwords), session channels only (no port forwarding, agent forwarding or SFTP), and it listens on the loopback address unless you change `-ssh-listen`. The host key is created in the data folder on first start and its fingerprint is written to the log, so you can check it the first time you connect. Not available on Windows.
- **Mail relay:** for a mail egress configured in the dashboard, opens a small SMTP relay on this machine (default `127.0.0.1:2525`). Point your mail server at it (Postfix: `relayhost = [127.0.0.1]:2525`) and each outgoing message leaves from the edge's public IP, so reverse DNS, SPF and your HELO name line up. Supports `AUTH PLAIN/LOGIN`, requires a password if it listens beyond `127.0.0.1`, relays one destination domain per transaction (extra domains get `452` so your server resends them separately), and passes your server's queue and retries through unchanged. 50 MB message limit.
## Deploying
@@ -42,10 +46,10 @@ Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea
**On a Linux server (systemd)**
```
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.0/bamboo_v0.1.0_linux_amd64.tar.gz
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.0/SHA256SUMS
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.6/bamboo_v0.1.6_linux_amd64.tar.gz
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.6/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
tar xzf bamboo_v0.1.0_linux_amd64.tar.gz && cd bamboo_v0.1.0_linux_amd64
tar xzf bamboo_v0.1.6_linux_amd64.tar.gz && cd bamboo_v0.1.6_linux_amd64
sudo ./install.sh # installs the binary and a hardened systemd service
sudoedit /etc/bamboo/bamboo.env # your settings and secrets
sudo systemctl start bamboo
+24
View File
@@ -7,11 +7,13 @@ import (
"log"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"git.veillax.com/Veillax/bamboo"
"git.veillax.com/Veillax/bamboo/discover"
"git.veillax.com/Veillax/bamboo/sshd"
"git.veillax.com/Veillax/reef/dotenv"
"git.veillax.com/Veillax/reef/selfupdate"
)
@@ -47,6 +49,9 @@ func main() {
discoverSubnets := flag.String("discover-subnets", os.Getenv("BAMBOO_DISCOVER_SUBNETS"), "comma-separated private subnets to scan for services, e.g. 192.168.1.0/24 (at most /22 each; off by default)")
discoverPorts := flag.String("discover-ports", os.Getenv("BAMBOO_DISCOVER_PORTS"), "TCP ports to try on scanned addresses, e.g. 22,80,443,8000-8010 (default: a short list of common ones)")
discoverRate := flag.Float64("discover-rate", envFloat("BAMBOO_DISCOVER_RATE", discover.DefaultRate), "most connection attempts per second discovery may make")
sshOn := flag.Bool("ssh", os.Getenv("BAMBOO_SSH") == "1", "run a built-in SSH server (public keys only) so this machine can be reached through a tunnelled port")
sshListen := flag.String("ssh-listen", env("BAMBOO_SSH_LISTEN", sshd.DefaultListen), "address the SSH server listens on; point a Hammerhead port at it from this site")
sshKeys := flag.String("ssh-keys", os.Getenv("BAMBOO_SSH_KEYS"), "authorized_keys file listing who may sign in (default: authorized_keys in the data folder)")
flag.String("env", ".env", "file to read secrets and settings from")
autoUpdate := flag.Bool("auto-update", os.Getenv("BAMBOO_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS)")
showVersion := flag.Bool("version", false, "print the version and exit")
@@ -74,6 +79,25 @@ func main() {
if dc.Ports, err = discover.ParsePorts(*discoverPorts); err != nil {
log.Fatalf("-discover-ports: %v", err)
}
if *sshOn {
if !sshd.Supported {
log.Fatal("-ssh: the SSH server is not available on this platform")
}
keys := *sshKeys
if keys == "" {
keys = filepath.Join(*data, "authorized_keys")
}
srv, err := sshd.New(sshd.Config{Listen: *sshListen, AuthorizedKeys: keys, HostKey: filepath.Join(*data, "ssh_host_ed25519_key"), Logf: log.Printf})
if err != nil {
log.Fatalf("-ssh: %v", err)
}
log.Printf("ssh: listening on %s, host key %s", *sshListen, srv.Fingerprint())
go func() {
if err := srv.Run(ctx); err != nil {
log.Printf("ssh: stopped: %v", err)
}
}()
}
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn, Discover: dc,
Version: version, AutoUpdate: *autoUpdate,
UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) {
+2 -1
View File
@@ -4,13 +4,14 @@ go 1.27.3
require (
git.veillax.com/Veillax/reef v0.1.11
github.com/creack/pty v1.1.24
golang.org/x/crypto v0.58.0
golang.org/x/time v0.7.0
)
require (
github.com/Microsoft/go-winio v0.6.4 // indirect
github.com/google/btree v1.1.2 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+4
View File
@@ -2,6 +2,8 @@ git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXug
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
@@ -10,6 +12,8 @@ golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
+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 }
+303
View File
@@ -0,0 +1,303 @@
// 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)
}
+200
View File
@@ -0,0 +1,200 @@
package sshd
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"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")
}
}
// Hammerhead health-checks a TCP resource by connecting and hanging up; that must not look like
// a failed sign-in in the log, while a real failure still does.
func TestProbesAreNotLogged(t *testing.T) {
good := newSigner(t)
dir := t.TempDir()
keys := filepath.Join(dir, "authorized_keys")
if err := os.WriteFile(keys, []byte(authLine(good)), 0o600); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var logs []string
srv, err := New(Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "host_key"), Logf: func(f string, a ...any) {
mu.Lock()
logs = append(logs, fmt.Sprintf(f, a...))
mu.Unlock()
}})
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 })
for i := 0; i < 3; i++ {
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
c.Close()
}
time.Sleep(200 * time.Millisecond)
if _, err := dial(ln.Addr().String(), ssh.PublicKeys(newSigner(t))); err == nil {
t.Fatal("an unknown key signed in")
}
time.Sleep(200 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if len(logs) != 1 || !strings.Contains(logs[0], "failed") {
t.Fatalf("want exactly the one real failure logged, got %q", logs)
}
}