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) } }