The shell's stderr was discarded, so a shell that could not start looked like an immediate disconnect. It now goes to the terminal like other output, followed by the exit code. An end-to-end test covers a machine with no shell. Builds against Bamboo v0.1.10, which says why when there is no shell to run. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
194 lines
5.5 KiB
Go
194 lines
5.5 KiB
Go
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)
|
|
}
|
|
|
|
// a machine with no shell (the published container image has none) says why, instead of the
|
|
// terminal just disconnecting: the shell's error output reaches the browser, then the exit code
|
|
if sshd.Supported {
|
|
t.Setenv("SHELL", "/nonexistent/shell")
|
|
ws := dial()
|
|
defer ws.Close()
|
|
var out bytes.Buffer
|
|
var exit termMsg
|
|
for exit.Type != "exit" {
|
|
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 shell to end (got %q): %v", out.String(), err)
|
|
}
|
|
if len(f) > 0 && f[0] == '{' {
|
|
if m := parseTermMsg(f); m.Type == "exit" {
|
|
exit = m
|
|
}
|
|
continue
|
|
}
|
|
out.Write(f)
|
|
}
|
|
if exit.Code != 127 || !strings.Contains(out.String(), "no shell to run") {
|
|
t.Errorf("a missing shell: exit %d, output %q", exit.Code, out.String())
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|