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>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 01:38:46 -06:00
1 parent 1fba65fd3e
commit a2403ecf9d
7 files changed
+225 -6

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.
- **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.
- **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.
## Deploying
+4
View File
@@ -37,6 +37,7 @@ type Config struct {
DataDir string // holds this agent's WireGuard key
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
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 string
@@ -57,6 +58,7 @@ type Bamboo struct {
tun *wg.Device
edges []model.EdgeRef
lastRep string
terms map[string]time.Time // terminal sessions already answered, by id
updating atomic.Bool
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.syncPeers()
b.syncMail(cfg.MailEgress)
b.openTerminals(cfg.Terminals)
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...)
b.mu.Unlock()
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})
req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/site/report", bytes.NewReader(body))
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 {
log.Fatalf("-discover-ports: %v", err)
}
sshAddr := "" // reported to Hammerhead, so its terminal can reach the server through this agent
if *sshOn {
if !sshd.Supported {
log.Fatal("-ssh: the SSH server is not available on this platform")
@@ -91,6 +92,7 @@ func main() {
if err != nil {
log.Fatalf("-ssh: %v", err)
}
sshAddr = *sshListen
log.Printf("ssh: listening on %s, host key %s", *sshListen, srv.Fingerprint())
go func() {
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,
UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) {
c := upd
+2 -2
View File
@@ -3,16 +3,16 @@ module git.veillax.com/Veillax/bamboo
go 1.27.3
require (
git.veillax.com/Veillax/reef v0.1.11
git.veillax.com/Veillax/reef v0.1.12
github.com/creack/pty v1.1.24
golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.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/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // 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.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s=
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/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+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):
}
}