Hammerhead signs in to the site's Bamboo SSH server through the SSH port an edge exposes, with a key of its own, and relays the shell to the browser over a WebSocket (xterm.js, served by Hammerhead). The SSH dialog shows Hammerhead's public key to add to authorized_keys. Host keys are saved on first use and a changed one is refused; the terminal opens only from the dashboard's own origin, is audited, and closes after 30 minutes idle. Errors reach the page as messages over the socket. An end-to-end test runs a real shell through the edge, tunnel and Bamboo. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
197 lines
6.0 KiB
Go
197 lines
6.0 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)
|
|
|
|
// asking before any SSH port exists says what to do
|
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "SSH port") {
|
|
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)
|
|
}
|
|
}
|