From a2403ecf9d1890d72e1cc2849c4c0ba7e5fa2483 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:38:46 -0600 Subject: [PATCH] Terminal relay: answer Hammerhead's terminal sessions through the agent 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 --- README.md | 2 +- bamboo.go | 4 ++ cmd/bamboo/main.go | 4 +- go.mod | 4 +- go.sum | 4 +- terminal.go | 99 +++++++++++++++++++++++++++++++++++++++ terminal_test.go | 114 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 terminal.go create mode 100644 terminal_test.go diff --git a/README.md b/README.md index 3745c4c..50f9460 100644 --- a/README.md +++ b/README.md @@ -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 user@`. 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 user@`. 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 diff --git a/bamboo.go b/bamboo.go index 91b0ab6..872c18f 100644 --- a/bamboo.go +++ b/bamboo.go @@ -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) diff --git a/cmd/bamboo/main.go b/cmd/bamboo/main.go index 32570d1..57f657c 100644 --- a/cmd/bamboo/main.go +++ b/cmd/bamboo/main.go @@ -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 diff --git a/go.mod b/go.mod index 6ec837c..e225d85 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d1f9df9..a50c073 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/terminal.go b/terminal.go new file mode 100644 index 0000000..54b85ed --- /dev/null +++ b/terminal.go @@ -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 +} diff --git a/terminal_test.go b/terminal_test.go new file mode 100644 index 0000000..e3fe033 --- /dev/null +++ b/terminal_test.go @@ -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): + } +}