2 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 79bd4d8646 ssh: say why when there is no shell to run
CI / test (push) Successful in 3m34s
Release / release (push) Successful in 5m4s
The published container image has no shell, so a session used to end at once with
nothing printed for a command. It now explains that, and what to do about it,
and exits with status 127.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 02:04:28 -06:00
VeillaxandClaude Sonnet 5.5 a2403ecf9d Terminal relay: answer Hammerhead's terminal sessions through the agent
CI / test (push) Successful in 3m51s
Release / release (push) Successful in 4m43s
When someone opens a terminal for this site, Hammerhead lists the session in the
site's config. The agent connects out to Hammerhead and joins that connection to
its own SSH server, so no port has to be open on an edge. The agent now reports
where its SSH server listens and that it can relay.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 01:38:46 -06:00
9 changed files with 262 additions and 6 deletions

No files matched your search

+1 -1
View File
@@ -36,7 +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. - **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. - **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. - **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. - **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. Hammerhead's dashboard terminal reaches this server through the agent itself: Bamboo reports where the server listens, and when someone opens a terminal it connects out to Hammerhead and joins that connection to the server, so no port has to be open anywhere. Add the public key Hammerhead shows (SSH button on the Sites page) to `authorized_keys` to allow it. 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. - **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 ## Deploying
+4
View File
@@ -37,6 +37,7 @@ type Config struct {
DataDir string // holds this agent's WireGuard key DataDir string // holds this agent's WireGuard key
Docker bool // report local Docker containers so they can be picked as targets Docker bool // report local Docker containers so they can be picked as targets
Discover discover.Config // opt-in: report listening services found on this host and configured subnets as suggested targets Discover discover.Config // opt-in: report listening services found on this host and configured subnets as suggested targets
SSH string // where this machine's SSH server listens ("" = not running); Hammerhead's terminal reaches it through the agent
// Version, AutoUpdate and UpdateNow work as in blacktip.Config. // Version, AutoUpdate and UpdateNow work as in blacktip.Config.
Version string Version string
@@ -57,6 +58,7 @@ type Bamboo struct {
tun *wg.Device tun *wg.Device
edges []model.EdgeRef edges []model.EdgeRef
lastRep string lastRep string
terms map[string]time.Time // terminal sessions already answered, by id
updating atomic.Bool updating atomic.Bool
updateMsg string // outcome of the update Hammerhead asked for, reported back updateMsg string // outcome of the update Hammerhead asked for, reported back
@@ -185,6 +187,7 @@ func (b *Bamboo) apply(cfg *model.SiteConfig) {
b.mu.Unlock() b.mu.Unlock()
b.syncPeers() b.syncPeers()
b.syncMail(cfg.MailEgress) b.syncMail(cfg.MailEgress)
b.openTerminals(cfg.Terminals)
go b.report() go b.report()
} }
@@ -284,6 +287,7 @@ func (b *Bamboo) report() {
msg, uid, stage, pct, ulog := b.updateMsg, b.updateID, b.updStage, b.updPct, append([]string(nil), b.updLog...) msg, uid, stage, pct, ulog := b.updateMsg, b.updateID, b.updStage, b.updPct, append([]string(nil), b.updLog...)
b.mu.Unlock() b.mu.Unlock()
body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, Discovered: found, MailEgress: b.mailStatus(), body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, Discovered: found, MailEgress: b.mailStatus(),
SSH: b.cfg.SSH, SSHRelay: true,
Version: b.cfg.Version, AutoUpdate: b.cfg.AutoUpdate, UpdateMsg: msg, UpdateID: uid, UpdateStage: stage, UpdatePct: pct, UpdateLog: ulog}) Version: b.cfg.Version, AutoUpdate: b.cfg.AutoUpdate, UpdateMsg: msg, UpdateID: uid, UpdateStage: stage, UpdatePct: pct, UpdateLog: ulog})
req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/site/report", bytes.NewReader(body)) req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/site/report", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+b.cfg.Token) req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
+3 -1
View File
@@ -79,6 +79,7 @@ func main() {
if dc.Ports, err = discover.ParsePorts(*discoverPorts); err != nil { if dc.Ports, err = discover.ParsePorts(*discoverPorts); err != nil {
log.Fatalf("-discover-ports: %v", err) log.Fatalf("-discover-ports: %v", err)
} }
sshAddr := "" // reported to Hammerhead, so its terminal can reach the server through this agent
if *sshOn { if *sshOn {
if !sshd.Supported { if !sshd.Supported {
log.Fatal("-ssh: the SSH server is not available on this platform") log.Fatal("-ssh: the SSH server is not available on this platform")
@@ -91,6 +92,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("-ssh: %v", err) log.Fatalf("-ssh: %v", err)
} }
sshAddr = *sshListen
log.Printf("ssh: listening on %s, host key %s", *sshListen, srv.Fingerprint()) log.Printf("ssh: listening on %s, host key %s", *sshListen, srv.Fingerprint())
go func() { go func() {
if err := srv.Run(ctx); err != nil { if err := srv.Run(ctx); err != nil {
@@ -98,7 +100,7 @@ func main() {
} }
}() }()
} }
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn, Discover: dc, b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn, Discover: dc, SSH: sshAddr,
Version: version, AutoUpdate: *autoUpdate, Version: version, AutoUpdate: *autoUpdate,
UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) { UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) {
c := upd c := upd
+2 -2
View File
@@ -3,16 +3,16 @@ module git.veillax.com/Veillax/bamboo
go 1.27.3 go 1.27.3
require ( require (
git.veillax.com/Veillax/reef v0.1.11 git.veillax.com/Veillax/reef v0.1.12
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0
golang.org/x/time v0.7.0 golang.org/x/time v0.7.0
) )
require ( require (
github.com/Microsoft/go-winio v0.6.4 // indirect github.com/Microsoft/go-winio v0.6.4 // indirect
github.com/google/btree v1.1.2 // indirect github.com/google/btree v1.1.2 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY= git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s=
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= git.veillax.com/Veillax/reef v0.1.12/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= 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/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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+8
View File
@@ -25,6 +25,14 @@ func run(ch ssh.Channel, cmd string, p *ptyReq, win <-chan [2]uint32) int {
if shell == "" { if shell == "" {
shell = "/bin/sh" shell = "/bin/sh"
} }
// Without this the client sees only a session that ends at once. The container image Bamboo is
// published in contains no shell at all, so say so, and what to do about it.
if _, err := exec.LookPath(shell); err != nil {
fmt.Fprintf(ch.Stderr(), "bamboo: there is no shell to run (%v).\r\n"+
"The Bamboo container image has none, and a shell in a container is not a shell on the machine. "+
"Run Bamboo directly on the machine (for example with its systemd unit) to get one, or set SHELL to a shell that exists.\r\n", err)
return 127
}
var c *exec.Cmd var c *exec.Cmd
if cmd == "" { if cmd == "" {
c = exec.Command(shell, "-l") c = exec.Command(shell, "-l")
+29
View File
@@ -82,3 +82,32 @@ func TestShell(t *testing.T) {
t.Fatalf("got %q, %v", out.String(), err) t.Fatalf("got %q, %v", out.String(), err)
} }
} }
// A machine with no shell (the published container image is one) must say so, not just hang up.
func TestNoShellIsExplained(t *testing.T) {
t.Setenv("SHELL", "/nonexistent/shell")
for name, usePty := range map[string]bool{"exec": false, "pty shell": true} {
s, done := session(t)
var stderr, stdout strings.Builder
s.Stderr, s.Stdout = &stderr, &stdout
var err error
if usePty {
if err = s.RequestPty("xterm", 24, 80, ssh.TerminalModes{}); err == nil {
err = s.Shell()
if err == nil {
err = s.Wait()
}
}
} else {
err = s.Run("true")
}
done()
var ee *ssh.ExitError
if !errors.As(err, &ee) || ee.ExitStatus() != 127 {
t.Errorf("%s: want exit status 127, got %v", name, err)
}
if !strings.Contains(stderr.String()+stdout.String(), "no shell to run") {
t.Errorf("%s: no explanation (stderr %q, stdout %q)", name, stderr.String(), stdout.String())
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package bamboo
import (
"io"
"log"
"net"
"strings"
"sync"
"time"
"golang.org/x/net/websocket"
)
// Terminal relay. When someone opens a terminal for this site in Hammerhead, Hammerhead lists a
// session in the site's config (model.SiteConfig.Terminals). For each one the agent opens a
// connection out to Hammerhead and joins it to its own SSH server, so Hammerhead can sign in over it.
// Nothing listens for this, no port has to be open on an edge, and the SSH server still decides who
// gets in: Hammerhead needs its public key in authorized_keys like any other client.
const (
termSeenFor = time.Minute // a session id is answered once, even if the config is fetched again
termDial = 10 * time.Second
)
// sshTarget is the address to dial for the local SSH server. An address that listens on every
// interface is reached on the loopback one.
func sshTarget(listen string) string {
if listen == "" {
return ""
}
host, port, err := net.SplitHostPort(listen)
if err != nil {
return listen
}
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// openTerminals starts a relay for each session Hammerhead is waiting on that has not been answered yet.
func (b *Bamboo) openTerminals(ids []string) {
for _, id := range ids {
b.mu.Lock()
if b.terms == nil {
b.terms = map[string]time.Time{}
}
for k, t := range b.terms {
if time.Since(t) > termSeenFor {
delete(b.terms, k)
}
}
_, dup := b.terms[id]
if !dup {
b.terms[id] = time.Now()
}
b.mu.Unlock()
if !dup {
go b.relayTerminal(id)
}
}
}
func (b *Bamboo) relayTerminal(id string) {
target := sshTarget(b.cfg.SSH)
if target == "" {
log.Printf("terminal: Hammerhead asked for a terminal but the SSH server is not running (start bamboo with -ssh)")
return
}
wsURL := "ws" + strings.TrimPrefix(b.cfg.Hammerhead, "http") + "/site/terminal/" + id
cfg, err := websocket.NewConfig(wsURL, b.cfg.Hammerhead)
if err != nil {
log.Printf("terminal: %v", err)
return
}
cfg.Header.Set("Authorization", "Bearer "+b.cfg.Token)
cfg.Dialer = &net.Dialer{Timeout: termDial}
ws, err := websocket.DialConfig(cfg)
if err != nil {
log.Printf("terminal: could not reach Hammerhead: %v", err)
return
}
ws.PayloadType = websocket.BinaryFrame
defer ws.Close()
local, err := net.DialTimeout("tcp", target, termDial)
if err != nil {
log.Printf("terminal: the SSH server at %s is not answering: %v", target, err)
return // closing the connection tells Hammerhead at once
}
defer local.Close()
var once sync.Once
stop := func() { once.Do(func() { ws.Close(); local.Close() }) }
done := make(chan struct{}, 2)
go func() { io.Copy(local, ws); stop(); done <- struct{}{} }()
go func() { io.Copy(ws, local); stop(); done <- struct{}{} }()
<-done
<-done
}
+114
View File
@@ -0,0 +1,114 @@
package bamboo
import (
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/net/websocket"
)
func TestSSHTarget(t *testing.T) {
for in, want := range map[string]string{
"": "",
"127.0.0.1:2222": "127.0.0.1:2222",
":2222": "127.0.0.1:2222",
"0.0.0.0:2200": "127.0.0.1:2200",
"[::]:2222": "127.0.0.1:2222",
"10.0.0.5:2222": "10.0.0.5:2222",
} {
if got := sshTarget(in); got != want {
t.Errorf("sshTarget(%q) = %q, want %q", in, got, want)
}
}
}
// Hammerhead lists a terminal session; the agent calls back with its token and joins the connection
// to its SSH server. Here the "SSH server" is a local echo, and Hammerhead is a test server.
func TestTerminalRelayJoinsHammerheadToTheLocalSSHServer(t *testing.T) {
echo, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer echo.Close()
go func() {
for {
c, err := echo.Accept()
if err != nil {
return
}
go func() { defer c.Close(); io.Copy(c, c) }()
}
}()
got := make(chan *websocket.Conn, 4)
auth := make(chan string, 4)
paths := make(chan string, 4)
hh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth <- r.Header.Get("Authorization")
paths <- r.URL.Path
release := make(chan struct{})
websocket.Server{Handshake: func(*websocket.Config, *http.Request) error { return nil }, Handler: func(ws *websocket.Conn) {
ws.PayloadType = websocket.BinaryFrame
got <- ws
<-release
}}.ServeHTTP(w, r)
close(release)
}))
defer hh.Close()
b, err := New(Config{Hammerhead: hh.URL, Token: "site-token", DataDir: t.TempDir(), SSH: echo.Addr().String()})
if err != nil {
t.Fatal(err)
}
b.openTerminals([]string{"abc"})
b.openTerminals([]string{"abc"}) // fetching the config again must not answer the same session twice
var ws *websocket.Conn
select {
case ws = <-got:
case <-time.After(5 * time.Second):
t.Fatal("the agent never called back")
}
if a := <-auth; a != "Bearer site-token" {
t.Errorf("call-back authorization: %q", a)
}
if p := <-paths; p != "/site/terminal/abc" {
t.Errorf("call-back path: %q", p)
}
if _, err := ws.Write([]byte("hello through the relay")); err != nil {
t.Fatal(err)
}
buf := make([]byte, 64)
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := ws.Read(buf)
if err != nil || string(buf[:n]) != "hello through the relay" {
t.Fatalf("echo through the relay: %q, %v", buf[:n], err)
}
select {
case <-got:
t.Error("the same session was answered twice")
case <-time.After(300 * time.Millisecond):
}
ws.Close()
}
// With no SSH server the agent does not call back at all, rather than connect and hang.
func TestTerminalRelayDoesNothingWithoutAnSSHServer(t *testing.T) {
called := make(chan struct{}, 1)
hh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called <- struct{}{} }))
defer hh.Close()
b, err := New(Config{Hammerhead: hh.URL, Token: "t", DataDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
b.openTerminals([]string{"x"})
select {
case <-called:
t.Error("the agent called Hammerhead although it has no SSH server")
case <-time.After(400 * time.Millisecond):
}
}