Files
Hammerhead/e2e/terminal_test.go
VeillaxandClaude Sonnet 5.5 35d4000bc4
CI / test (push) Successful in 5m50s
Release / release (push) Successful in 5m42s
Terminal without an open port: the agent calls back, and the SSH dialog shows its state
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>
2026-10-31 01:40:16 -06:00

197 lines
6.1 KiB
Go

package e2e
import (
"bytes"
"context"
"encoding/json"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/bamboo/sshd"
"git.veillax.com/Veillax/blacktip"
"golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
)
type termMsg struct {
Type, Message string
Code int
HostKeyChanged bool
}
// The dashboard's terminal: browser, Hammerhead (as the SSH client), the SSH port on the edge,
// the WireGuard tunnel, Bamboo, and Bamboo's SSH server.
func TestWebTerminalThroughTheEdgeToBamboosServer(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
base := "/api/orgs/" + w.org
u, _ := url.Parse(w.ts.URL)
wsURL := "ws" + strings.TrimPrefix(w.ts.URL, "http") + base + "/sites/" + w.site + "/terminal?cols=100&rows=30"
// dial opens the terminal's WebSocket as the administrator, or as nobody
dial := func(origin string, signedIn bool) (*websocket.Conn, error) {
cfg, err := websocket.NewConfig(wsURL, origin)
if err != nil {
t.Fatal(err)
}
if signedIn {
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, "; "))
}
return websocket.DialConfig(cfg)
}
// control reads until the next text frame: the terminal's own messages ("ready", "error", "exit")
control := func(ws *websocket.Conn) termMsg {
t.Helper()
for {
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 terminal: %v", err)
}
var m termMsg
if json.Unmarshal(f, &m) == nil && m.Type != "" && f[0] == '{' {
return m
}
}
}
// first says how a new terminal begins
first := func() termMsg {
t.Helper()
ws, err := dial(w.ts.URL, true)
if err != nil {
t.Fatal(err)
}
defer ws.Close()
return control(ws)
}
// nobody signed in, or a page on another site, cannot open it
if ws, err := dial(w.ts.URL, false); err == nil {
ws.Close()
t.Error("the terminal opened for someone who is not signed in")
}
if ws, err := dial("https://evil.example", true); err == nil {
ws.Close()
t.Error("a page on another site opened the terminal with the administrator's cookie")
}
anon := newClient(t, w.ts.URL)
anon.expect(401, "GET", base+"/terminal", nil)
// this Bamboo has no SSH server of its own to call back to, so before a port exists the message says to start it
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "-ssh") {
t.Errorf("before opening an SSH port: %+v", m)
}
// Hammerhead's public key, which goes in the site's authorized_keys
var key struct{ PublicKey, Fingerprint string }
w.admin.must("GET", base+"/terminal", nil, &key)
if !strings.HasPrefix(key.PublicKey, "ssh-ed25519 ") || strings.Contains(key.PublicKey, "PRIVATE") {
t.Fatalf("public key: %q", key.PublicKey)
}
var again struct{ PublicKey string }
w.admin.must("GET", base+"/terminal", nil, &again)
if again.PublicKey != key.PublicKey {
t.Fatal("the key changed between requests")
}
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)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go srv.Serve(ctx, ln)
edgePort := freePort(t)
w.admin.must("POST", base+"/mappings", map[string]any{"name": "ssh", "proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": edgePort,
"enabled": true, "tags": []string{"ssh"}, "targets": tgt(w.site, ln.Addr().(*net.TCPAddr).Port)}, nil)
// wait for the path through the tunnel to come up
var m termMsg
for i := 0; i < 60; i++ {
if m = first(); !strings.Contains(m.Message, "reach the site") {
break
}
time.Sleep(250 * time.Millisecond)
}
if sshd.Supported {
if m.Type != "ready" {
t.Fatalf("opening the terminal: %+v", m)
}
} else if m.Type != "error" || !strings.Contains(m.Message, "terminal") {
// this platform can't run shells, but asking for one proves Hammerhead's key was accepted
t.Fatalf("on a platform without shells: %+v", m)
}
if sshd.Supported {
ws, err := dial(w.ts.URL, true)
if err != nil {
t.Fatal(err)
}
defer ws.Close()
if m := control(ws); m.Type != "ready" {
t.Fatalf("second terminal: %+v", m)
}
// $((40+2)) is expanded by the shell, so "terminal-42" can only come from the command running
if err := websocket.Message.Send(ws, []byte("echo terminal-$((40+2))\n")); err != nil {
t.Fatal(err)
}
var out strings.Builder
for !strings.Contains(out.String(), "terminal-42\r\n") {
ws.SetReadDeadline(time.Now().Add(10 * 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)
}
websocket.Message.Send(ws, `{"type":"resize","cols":120,"rows":40}`)
websocket.Message.Send(ws, []byte("exit\n"))
var sawExit bool
for !sawExit {
ws.SetReadDeadline(time.Now().Add(10 * time.Second))
var f []byte
if err := websocket.Message.Receive(ws, &f); err != nil {
break
}
sawExit = bytes.Contains(f, []byte(`"type":"exit"`))
}
if !sawExit {
t.Error("the browser was not told the shell ended")
}
}
// taking Hammerhead's key out of authorized_keys is refused with a message that says what to fix
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 the key: %+v", m)
}
// a site whose SSH server has stopped is reported, not left hanging
cancel()
ln.Close()
time.Sleep(300 * time.Millisecond)
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "reach the site") {
t.Errorf("with the SSH server stopped: %+v", m)
}
}