Terminal without an open port: the agent calls back, and the SSH dialog shows its state
CI / test (push) Successful in 5m50s
Release / release (push) Successful in 5m42s

Bamboo reports where its SSH server listens. When a terminal is opened,
Hammerhead lists the session in the site's config and the agent connects out and
joins that connection to its SSH server; Hammerhead signs in over it. No port
resource or edge is involved. A site whose Bamboo does not report an SSH server
still works through an SSH port. The long poll now returns at once for a session
the agent has not been told about, so a request between two polls is not missed.

The SSH dialog shows whether the SSH server is running, says when no port is
needed, and offers the port only for an ordinary ssh client. Builds against Reef
v0.1.12 and Bamboo v0.1.9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 01:40:16 -06:00
1 parent 66483d8385
commit 35d4000bc4
10 files changed
+372 -32

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
package e2e
import (
"bytes"
"context"
"encoding/json"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/bamboo"
"git.veillax.com/Veillax/bamboo/sshd"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
)
// With Bamboo reporting its SSH server, the terminal works with no SSH port anywhere: Hammerhead
// asks the agent to call back and signs in over that connection.
func TestWebTerminalWithoutAnyOpenPort(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
siteCfgHook = func(c *bamboo.Config) { c.SSH = ln.Addr().String() } // what bamboo -ssh tells the agent
w := setup(t, blacktip.TraefikConfig{})
siteCfgHook = nil
base := "/api/orgs/" + w.org
u, _ := url.Parse(w.ts.URL)
var key struct{ PublicKey string }
w.admin.must("GET", base+"/terminal", nil, &key)
dir := t.TempDir()
keys := filepath.Join(dir, "authorized_keys")
if err := os.WriteFile(keys, []byte(key.PublicKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
srv, err := sshd.New(sshd.Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "ssh_host_ed25519_key")})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go srv.Serve(ctx, ln)
// the agent's next report tells Hammerhead where its SSH server is
var st struct{ Sites []model.Site }
for i := 0; i < 60; i++ {
w.admin.must("GET", base+"/state", nil, &st)
for _, s := range st.Sites {
if s.ID == w.site && s.SSH != "" {
i = 60
}
}
if i < 60 {
time.Sleep(200 * time.Millisecond)
}
}
var site model.Site
for _, s := range st.Sites {
if s.ID == w.site {
site = s
}
}
if site.SSH != ln.Addr().String() || !site.SSHRelay {
t.Fatalf("the site did not report its SSH server: %+v", site)
}
wsURL := "ws" + strings.TrimPrefix(w.ts.URL, "http") + base + "/sites/" + w.site + "/terminal?cols=100&rows=30"
dial := func() *websocket.Conn {
t.Helper()
cfg, err := websocket.NewConfig(wsURL, w.ts.URL)
if err != nil {
t.Fatal(err)
}
var ck []string
for _, c := range w.admin.c.Jar.Cookies(u) {
ck = append(ck, c.Name+"="+c.Value)
}
cfg.Header.Set("Cookie", strings.Join(ck, "; "))
ws, err := websocket.DialConfig(cfg)
if err != nil {
t.Fatal(err)
}
return ws
}
first := func() (m termMsg) {
t.Helper()
ws := dial()
defer ws.Close()
for {
ws.SetReadDeadline(time.Now().Add(20 * time.Second))
var f []byte
if err := websocket.Message.Receive(ws, &f); err != nil {
t.Fatalf("waiting for the terminal: %v", err)
}
if len(f) > 0 && f[0] == '{' {
return parseTermMsg(f)
}
}
}
m := first()
if sshd.Supported {
if m.Type != "ready" {
t.Fatalf("opening the terminal with no port: %+v", m)
}
ws := dial()
defer ws.Close()
// $((40+2)) is expanded by the shell, so "relay-42" can only come from the command running
if err := websocket.Message.Send(ws, []byte("echo relay-$((40+2))\n")); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
for !strings.Contains(out.String(), "relay-42\r\n") {
ws.SetReadDeadline(time.Now().Add(15 * time.Second))
var f []byte
if err := websocket.Message.Receive(ws, &f); err != nil {
t.Fatalf("waiting for the command's output (got %q): %v", out.String(), err)
}
out.Write(f)
}
} else if m.Type != "error" || !strings.Contains(m.Message, "terminal") {
// this platform can't run shells, but asking for one proves the call-back and Hammerhead's key worked
t.Fatalf("with no port, on a platform without shells: %+v", m)
}
// it really did not need a port
var maps struct{ Mappings []model.Mapping }
w.admin.must("GET", base+"/state", nil, &maps)
if len(maps.Mappings) != 0 {
t.Errorf("the test opened a port after all: %+v", maps.Mappings)
}
// the SSH server still decides who gets in
other := newSSHSigner(t)
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(other.PublicKey()), 0o600); err != nil {
t.Fatal(err)
}
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "authorized_keys") {
t.Errorf("after removing Hammerhead's key: %+v", m)
}
// and a stopped SSH server is reported at once rather than after a timeout
cancel()
ln.Close()
time.Sleep(300 * time.Millisecond)
start := time.Now()
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "SSH server") {
t.Errorf("with the SSH server stopped: %+v", m)
}
if time.Since(start) > 8*time.Second {
t.Errorf("a stopped SSH server took %s to be reported", time.Since(start))
}
}
func parseTermMsg(b []byte) termMsg {
var m termMsg
json.Unmarshal(b, &m)
return m
}