4 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 7481b2d01c Terminal: show the shell's error output, so a missing shell is explained
CI / test (push) Successful in 6m9s
Release / release (push) Successful in 5m54s
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>
2026-10-31 02:05:48 -06:00
VeillaxandClaude Sonnet 5.5 35d4000bc4 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>
2026-10-31 01:40:16 -06:00
VeillaxandClaude Sonnet 5.5 66483d8385 Web terminal: open a shell on a site from the dashboard
CI / test (push) Successful in 3m51s
Release / release (push) Successful in 6m28s
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>
2026-10-31 01:02:28 -06:00
VeillaxandClaude Sonnet 5.5 1c1f2b1d44 SSH access from the Sites page; containers and discovery dialog; dialogs fit small screens
CI / test (push) Successful in 6m5s
Release / release (push) Successful in 6m26s
Each site has an SSH button: it opens a TCP port on an edge whose target is
Bamboo's built-in SSH server, and shows the ssh command to use. An end-to-end
test signs in through the edge, the tunnel and Bamboo's server.

The Found by dialog now lists Docker containers and discovered services, wraps
long names and groups TCP and UDP ports. The dialog overlay no longer lets a
wide table push a dialog off a phone screen. Builds against Bamboo v0.1.8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 00:34:05 -06:00
18 changed files with 1588 additions and 17 deletions

No files matched your search

+11
View File
@@ -163,6 +163,17 @@ for each provider (e.g. `CF_DNS_API_TOKEN` for `cloudflare`); `.env` is the plac
- **Port ranges** for tcp and udp: `2456-2458` opens all three, each sent to the target port plus the - **Port ranges** for tcp and udp: `2456-2458` opens all three, each sent to the target port plus the
same offset (up to 1000 ports per entry; a busy port inside a range is named in the status). same offset (up to 1000 ports per entry; a busy port inside a range is named in the status).
- TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI. - TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
- **SSH into a site, and a terminal in the dashboard.** Start Bamboo with `-ssh` and list the allowed public
keys in its `authorized_keys`. The **SSH** button on the Sites page shows Hammerhead's own public key: add
it to `authorized_keys` and **Open terminal** gives an administrator a shell in the browser. No port has to
be open anywhere: Bamboo reports its SSH server, and when a terminal is opened it calls back to Hammerhead
and joins that connection to the server. Hammerhead is the SSH client, saves each site's host key the
first time and refuses a different one later (with a button to forget it), allows a terminal only from the
dashboard's own origin, logs opening and closing in the audit log, and closes an idle one after 30
minutes. The shell runs as the user Bamboo runs as, so treat Hammerhead's key like a root login if Bamboo
runs as root. To connect from your own computer with an ordinary `ssh` client, the same dialog can open a
TCP port on an edge to that server and shows the command to use. An older Bamboo that does not report its
SSH server can still be reached through such a port.
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP - **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
+3 -1
View File
@@ -195,7 +195,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64) have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
if !s.wait(r, have) { if !s.waitUntil(r, have, func() bool { return s.newTerminalsLocked(st.ID) }) {
s.mu.Lock() s.mu.Lock()
if st := s.siteByToken(r); st != nil { if st := s.siteByToken(r); st != nil {
st.LastSeen = time.Now() st.LastSeen = time.Now()
@@ -235,6 +235,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password}) cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
} }
} }
cfg.Terminals = s.waitingTerminalsLocked(st.ID)
writeJSON(w, 200, cfg) writeJSON(w, 200, cfg)
} }
@@ -260,6 +261,7 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version) s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
s.containers[st.ID] = in.Containers s.containers[st.ID] = in.Containers
s.discovered[st.ID] = cleanDiscovered(in.Discovered) s.discovered[st.ID] = cleanDiscovered(in.Discovered)
st.SSH, st.SSHRelay = clipText(in.SSH, 100), in.SSHRelay
for _, x := range s.st.MailEgress { for _, x := range s.st.MailEgress {
if x.SiteID != st.ID { if x.SiteID != st.ID {
continue continue
+2 -1
View File
@@ -128,6 +128,7 @@ func (d *DB) sealed(v any) any {
case Settings: case Settings:
x.SMTP.Pass = b.Seal(x.SMTP.Pass) x.SMTP.Pass = b.Seal(x.SMTP.Pass)
x.Sandbar.Token = b.Seal(x.Sandbar.Token) x.Sandbar.Token = b.Seal(x.Sandbar.Token)
x.Terminal.Key = b.Seal(x.Terminal.Key)
return x return x
} }
return v return v
@@ -157,7 +158,7 @@ func (d *DB) unseal(v any) error {
case *AlertChannel: case *AlertChannel:
return errors.Join(d.open1(&x.Target), d.open1(&x.Token)) return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
case *Settings: case *Settings:
return errors.Join(d.open1(&x.SMTP.Pass), d.open1(&x.Sandbar.Token)) return errors.Join(d.open1(&x.SMTP.Pass), d.open1(&x.Sandbar.Token), d.open1(&x.Terminal.Key))
} }
return nil return nil
} }
+108
View File
@@ -0,0 +1,108 @@
package e2e
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/bamboo/sshd"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/ssh"
)
func newSSHSigner(t *testing.T) ssh.Signer {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
s, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
return s
}
// A TCP port on the edge whose target is Bamboo's SSH server at the site reaches that server
// with an ordinary ssh client: the path is client, edge, WireGuard tunnel, Bamboo, sshd.
func TestSSHThroughTheEdgeToBamboosServer(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
dir := t.TempDir()
signer := newSSHSigner(t)
keys := filepath.Join(dir, "authorized_keys")
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(signer.PublicKey()), 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.open(model.Mapping{Proto: model.ProtoTCP, Port: edgePort, Name: "ssh", Targets: tgt(w.site, ln.Addr().(*net.TCPAddr).Port)})
addr := "127.0.0.1:" + strconv.Itoa(edgePort)
connect := func(auth ssh.AuthMethod) (*ssh.Client, error) {
return ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "alex", Auth: []ssh.AuthMethod{auth}, Timeout: 5 * time.Second,
// the key the client sees through the tunnel must be Bamboo's, not something in between
HostKeyCallback: func(_ string, _ net.Addr, k ssh.PublicKey) error {
if got := ssh.FingerprintSHA256(k); got != srv.Fingerprint() {
return fmt.Errorf("host key %s is not the Bamboo server's %s", got, srv.Fingerprint())
}
return nil
}})
}
var client *ssh.Client
var last error
for i := 0; i < 60 && client == nil; i++ {
if client, last = connect(ssh.PublicKeys(signer)); client == nil {
time.Sleep(250 * time.Millisecond)
}
}
if client == nil {
t.Fatalf("could not sign in through the edge: %v", last)
}
defer client.Close()
if sshd.Supported {
sess, err := client.NewSession()
if err != nil {
t.Fatal(err)
}
out, err := sess.Output("echo through-the-tunnel")
sess.Close()
if err != nil || strings.TrimSpace(string(out)) != "through-the-tunnel" {
t.Fatalf("running a command: %q, %v", out, err)
}
}
// the same narrow rules hold through the tunnel
if c, err := connect(ssh.PublicKeys(newSSHSigner(t))); err == nil {
c.Close()
t.Error("a key that is not in authorized_keys signed in")
}
if c, err := connect(ssh.Password("hunter2")); err == nil {
c.Close()
t.Error("a password signed in")
}
if c, err := client.Dial("tcp", "127.0.0.1:1"); err == nil {
c.Close()
t.Error("port forwarding through the SSH server is allowed")
}
}
+193
View File
@@ -0,0 +1,193 @@
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
}
+196
View File
@@ -0,0 +1,196 @@
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)
}
}
+4 -3
View File
@@ -3,23 +3,24 @@ module git.veillax.com/Veillax/hammerhead
go 1.27.3 go 1.27.3
require ( require (
git.veillax.com/Veillax/bamboo v0.1.7 git.veillax.com/Veillax/bamboo v0.1.10
git.veillax.com/Veillax/blacktip v0.1.10 git.veillax.com/Veillax/blacktip v0.1.10
git.veillax.com/Veillax/reef v0.1.11 git.veillax.com/Veillax/reef v0.1.12
github.com/emersion/go-msgauth v0.7.0 github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0
modernc.org/sqlite v1.61.0 modernc.org/sqlite v1.61.0
) )
require ( require (
github.com/Microsoft/go-winio v0.6.4 // indirect github.com/Microsoft/go-winio v0.6.4 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect github.com/dustin/go-humanize v1.1.0 // indirect
github.com/google/btree v1.1.2 // indirect github.com/google/btree v1.1.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.7.0 // indirect golang.org/x/time v0.7.0 // indirect
+8 -4
View File
@@ -1,11 +1,13 @@
git.veillax.com/Veillax/bamboo v0.1.7 h1:men+7c+MyAbpQmNZKxwdO7yPdRgLtec2KuU4EQ4/EFA= git.veillax.com/Veillax/bamboo v0.1.10 h1:R21+L54cS87VXmOS4eAsfRwp6vL+ORLqJUBaRHBIlJE=
git.veillax.com/Veillax/bamboo v0.1.7/go.mod h1:hUMWRFuDIiTNaqjpx2F7RxOg7ussrbVZAQIhzhPF780= git.veillax.com/Veillax/bamboo v0.1.10/go.mod h1:Z3O7xoa52mFaADs7RxaBQl0a0K+5HlHPB1eOYQjM15k=
git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE= git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE=
git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o= git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o=
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY= git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s=
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= git.veillax.com/Veillax/reef v0.1.12/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc= github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
@@ -34,6 +36,8 @@ golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
+5
View File
@@ -45,6 +45,10 @@ func (s *Server) Handler() http.Handler {
s.registerAlertRoutes(mux) s.registerAlertRoutes(mux)
mux.HandleFunc("GET /metrics", s.metrics) mux.HandleFunc("GET /metrics", s.metrics)
mux.Handle("GET /static/term/", terminalStatic())
mux.HandleFunc("GET /api/orgs/{org}/terminal", s.withOrg(true, s.terminalKey))
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/terminal", s.withOrg(true, s.siteTerminal))
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}/terminal/hostkey", s.withOrg(true, s.forgetHostKey))
// public // public
mux.HandleFunc("GET /api/public", s.publicInfo) mux.HandleFunc("GET /api/public", s.publicInfo)
@@ -171,5 +175,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /edge/logs", s.edgeLogs) mux.HandleFunc("POST /edge/logs", s.edgeLogs)
mux.HandleFunc("GET /site/config", s.siteConfig) mux.HandleFunc("GET /site/config", s.siteConfig)
mux.HandleFunc("POST /site/report", s.siteReport) mux.HandleFunc("POST /site/report", s.siteReport)
mux.HandleFunc("GET /site/terminal/{id}", s.terminalCallBack)
return mux return mux
} }
+2
View File
@@ -18,6 +18,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *principa
st := s.st.Settings st := s.st.Settings
st.SMTP.Pass = "" // write-only st.SMTP.Pass = "" // write-only
st.Sandbar.Token, st.Sandbar.Monitors = "", nil // write-only st.Sandbar.Token, st.Sandbar.Monitors = "", nil // write-only
st.Terminal = TerminalSettings{} // the private key never leaves the server
writeJSON(w, 200, map[string]any{"settings": st, "smtpPassSet": s.st.Settings.SMTP.Pass != "", "sandbarTokenSet": s.st.Settings.Sandbar.Token != "", "sandbarSync": s.sandbarStatus}) writeJSON(w, 200, map[string]any{"settings": st, "smtpPassSet": s.st.Settings.SMTP.Pass != "", "sandbarTokenSet": s.st.Settings.Sandbar.Token != "", "sandbarSync": s.sandbarStatus})
} }
@@ -48,6 +49,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principa
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
} }
in.SelfEdgeID = s.st.Settings.SelfEdgeID // not editable here; losing it would make the next start create a second self edge in.SelfEdgeID = s.st.Settings.SelfEdgeID // not editable here; losing it would make the next start create a second self edge
in.Terminal = s.st.Settings.Terminal // the terminal key and trusted host keys are not editable here either
s.st.Settings = in s.st.Settings = in
s.bump() // edges receive the dashboard URL and ACME settings s.bump() // edges receive the dashboard URL and ACME settings
s.mu.Unlock() s.mu.Unlock()
+12 -2
View File
@@ -120,6 +120,8 @@ type Settings struct {
Sandbar SandbarSync `json:"sandbar"` Sandbar SandbarSync `json:"sandbar"`
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN). // SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
SelfEdgeID string `json:"selfEdgeId,omitempty"` SelfEdgeID string `json:"selfEdgeId,omitempty"`
// Terminal holds the web terminal's SSH key and the host keys it has seen (see terminal.go).
Terminal TerminalSettings `json:"terminal"`
} }
type Secret struct { type Secret struct {
@@ -208,6 +210,7 @@ type Server struct {
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
containers map[string][]model.Container // by site ID containers map[string][]model.Container // by site ID
discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found
terms map[string]*pendingTerm // terminal sessions waiting for an agent to call back, by session ID
changed chan struct{} changed chan struct{}
tickets map[string]ticket tickets map[string]ticket
@@ -261,6 +264,7 @@ func New(dir string, opts Options) (*Server, error) {
mailChecks: map[string]model.MailCheck{}, mailChecks: map[string]model.MailCheck{},
containers: map[string][]model.Container{}, containers: map[string][]model.Container{},
discovered: map[string][]model.DiscoveredService{}, discovered: map[string][]model.DiscoveredService{},
terms: map[string]*pendingTerm{},
changed: make(chan struct{}), changed: make(chan struct{}),
tickets: map[string]ticket{}, tickets: map[string]ticket{},
oauth: map[string]oauthState{}, oauth: map[string]oauthState{},
@@ -410,9 +414,15 @@ func (s *Server) bump() {
} }
// wait blocks until the version moves past have, or 25s pass. It returns false on timeout. // wait blocks until the version moves past have, or 25s pass. It returns false on timeout.
func (s *Server) wait(r *http.Request, have int64) bool { func (s *Server) wait(r *http.Request, have int64) bool { return s.waitUntil(r, have, nil) }
// waitUntil is wait that also returns at once when ready (called with s.mu held) says there is
// something to deliver that does not move the version, such as a terminal waiting for an agent.
// Checking it under the same lock that takes the change channel is what keeps a request that
// arrives between two polls from being missed.
func (s *Server) waitUntil(r *http.Request, have int64, ready func() bool) bool {
s.mu.Lock() s.mu.Lock()
if s.st.Version > have { if s.st.Version > have || (ready != nil && ready()) {
s.mu.Unlock() s.mu.Unlock()
return true return true
} }
+581
View File
@@ -0,0 +1,581 @@
package hammerhead
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"embed"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
)
// The web terminal opens a shell on a site from the dashboard. Hammerhead is the SSH client: it
// signs in to the SSH port that an edge exposes for the site (a TCP resource whose target is
// Bamboo's built-in SSH server) with a key of its own, and relays the shell to the browser over a
// WebSocket. Bamboo only lets in keys listed in its authorized_keys, so a site is reachable this
// way only after someone puts Hammerhead's public key there.
//go:embed web/term
var termFiles embed.FS
const (
sshTargetPort = 2222 // where Bamboo's SSH server listens by default
termIdle = 30 * time.Minute
termMaxSessions = 8
termMaxFrame = 64 << 10
termDialTimeout = 10 * time.Second
termDefaultTerm = "xterm-256color"
termResizeMaxCol = 500
termResizeMaxRow = 200
)
// TerminalSettings is what the web terminal remembers: its own SSH key, and the host key each
// site showed the first time (trusted on first use, like an ordinary ssh client).
type TerminalSettings struct {
Key string `json:"key,omitempty"` // PKCS#8 PEM private key; sealed at rest, never sent to the browser
Hosts map[string]string `json:"hosts,omitempty"` // site ID -> host key fingerprint
}
// terminalStatic serves the embedded xterm.js files.
func terminalStatic() http.Handler {
sub, _ := fs.Sub(termFiles, "web/term")
fileServer := http.StripPrefix("/static/term/", http.FileServer(http.FS(sub)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("X-Content-Type-Options", "nosniff")
fileServer.ServeHTTP(w, r)
})
}
// terminalSigner returns Hammerhead's SSH key, making it on first use. Caller holds s.mu.
func (s *Server) terminalSignerLocked() (ssh.Signer, error) {
if s.st.Settings.Terminal.Key == "" {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
s.st.Settings.Terminal.Key = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
s.save()
}
return ssh.ParsePrivateKey([]byte(s.st.Settings.Terminal.Key))
}
// terminalKey tells an administrator which public key to put in a site's authorized_keys.
func (s *Server) terminalKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
signer, err := s.terminalSignerLocked()
s.mu.Unlock()
if err != nil {
fail(w, 500, err)
return
}
line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) + " hammerhead"
writeJSON(w, 200, map[string]any{"publicKey": line, "fingerprint": ssh.FingerprintSHA256(signer.PublicKey())})
}
// isSSHResource reports whether m is a TCP port that leads to the SSH server of the given site.
func isSSHResource(m *model.Mapping, siteID string) bool {
if m.Proto != model.ProtoTCP || m.IsRedirect() || !m.Enabled {
return false
}
for _, t := range m.Targets {
if t.SiteID != siteID {
continue
}
if t.Port == sshTargetPort {
return true
}
for _, tag := range m.Tags {
if tag == "ssh" {
return true
}
}
}
return false
}
// terminalAddr finds where to connect for a site: the public address of the SSH port that
// leads to it. A port bound to every address is reached through the edge's own address.
func (s *Server) terminalAddr(c *orgCtx, siteID string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(siteID)
if st == nil || st.OrgID != c.Org.ID || st.Local {
return "", errors.New("no such site")
}
for _, m := range s.st.Mappings {
if m.OrgID != c.Org.ID || !isSSHResource(m, siteID) {
continue
}
host := m.BindIP
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
e := s.findEdge(m.EdgeID)
if e == nil {
continue
}
h, _, err := net.SplitHostPort(e.TunnelAddr)
if err != nil || h == "" {
continue
}
host = h
}
return net.JoinHostPort(host, strconv.Itoa(m.Port)), nil
}
return "", errors.New("This site has no SSH port yet. Open one from the SSH button on the Sites page.")
}
type hostKeyChanged struct{ site, was, now string }
func (e *hostKeyChanged) Error() string {
return fmt.Sprintf("The host key of %s has changed (was %s, now %s). If you reinstalled Bamboo on it, forget the saved key and try again.", e.site, e.was, e.now)
}
// trustHostKey remembers a site's host key the first time and refuses a different one afterwards.
func (s *Server) trustHostKey(siteID, siteName string) ssh.HostKeyCallback {
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
fp := ssh.FingerprintSHA256(key)
s.mu.Lock()
defer s.mu.Unlock()
t := &s.st.Settings.Terminal
switch known := t.Hosts[siteID]; {
case known == "":
if t.Hosts == nil {
t.Hosts = map[string]string{}
}
t.Hosts[siteID] = fp
s.save()
return nil
case known != fp:
return &hostKeyChanged{siteName, known, fp}
}
return nil
}
}
func (s *Server) forgetHostKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
if st := s.findSite(id); st == nil || st.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such site"))
return
}
delete(s.st.Settings.Terminal.Hosts, id)
s.save()
s.audit(c.Org.ID, c.Actor, "terminal.forget-host-key", id)
ok(w)
}
// sameOrigin lets a browser open the terminal only from this dashboard: a page on another site
// cannot use a signed-in administrator's cookie to start a shell. Requests without an Origin
// header do not come from a browser page.
func sameOrigin(r *http.Request) bool {
o := r.Header.Get("Origin")
if o == "" {
return true
}
u, err := url.Parse(o)
return err == nil && strings.EqualFold(u.Host, r.Host)
}
type termFrame struct {
text bool
data []byte
}
// frames reads and writes WebSocket frames keeping their type: binary frames carry the terminal's
// bytes, text frames carry control messages (resize, exit).
var frames = websocket.Codec{
Marshal: func(v any) ([]byte, byte, error) {
f := v.(termFrame)
if f.text {
return f.data, websocket.TextFrame, nil
}
return f.data, websocket.BinaryFrame, nil
},
Unmarshal: func(data []byte, pt byte, v any) error {
*(v.(*termFrame)) = termFrame{text: pt == websocket.TextFrame, data: data}
return nil
},
}
type termControl struct {
Type string `json:"type"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"` // type "error": what went wrong, in words for the person at the keyboard
HostKeyChanged bool `json:"hostKeyChanged,omitempty"` // type "error": the site's host key is not the one saved, so offer to forget it
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
var termSlots = make(chan struct{}, termMaxSessions)
// siteTerminal is the WebSocket that carries one shell session. The connection is accepted first
// and any problem (no SSH port, a refused key, a changed host key) is sent as an "error" message,
// because a browser cannot read the body of a failed WebSocket handshake.
func (s *Server) siteTerminal(w http.ResponseWriter, r *http.Request, c *orgCtx) {
if !sameOrigin(r) {
fail(w, 403, errors.New("The terminal can only be opened from the dashboard itself."))
return
}
siteID := r.PathValue("id")
cols, _ := strconv.Atoi(r.URL.Query().Get("cols"))
rows, _ := strconv.Atoi(r.URL.Query().Get("rows"))
cols, rows = clamp(cols, 20, termResizeMaxCol), clamp(rows, 5, termResizeMaxRow)
srv := websocket.Server{
Handshake: func(*websocket.Config, *http.Request) error { return nil }, // the origin was checked above
Handler: func(ws *websocket.Conn) {
ws.MaxPayloadBytes = termMaxFrame
s.runTerminal(ws, c, siteID, cols, rows)
},
}
srv.ServeHTTP(w, r)
}
func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols, rows int) {
var wmu sync.Mutex
send := func(f termFrame) error {
wmu.Lock()
defer wmu.Unlock()
return frames.Send(ws, f)
}
control := func(v termControl) {
msg, _ := json.Marshal(v)
send(termFrame{text: true, data: msg})
}
problem := func(msg string) {
control(termControl{Type: "error", Message: msg})
time.Sleep(200 * time.Millisecond) // let it reach the browser before the connection closes
}
defer ws.Close()
s.mu.Lock()
signer, err := s.terminalSignerLocked()
siteName, viaAgent, agentCanRelay := "", false, false
if st := s.findSite(siteID); st != nil {
siteName, viaAgent, agentCanRelay = st.Name, st.SSH != "" && st.SSHRelay, st.SSHRelay
}
s.mu.Unlock()
if err != nil {
problem(err.Error())
return
}
select {
case termSlots <- struct{}{}:
defer func() { <-termSlots }()
default:
problem("Too many terminals are open. Close one and try again.")
return
}
cfg := &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: s.trustHostKey(siteID, siteName), Timeout: termDialTimeout}
var client *ssh.Client
if viaAgent {
// Bamboo reports its SSH server, so the agent calls back and no port has to be open anywhere.
var release func()
client, release, err = s.dialViaAgent(siteID, cfg)
defer func() {
if release != nil {
release()
}
}()
} else {
addr, aerr := s.terminalAddr(c, siteID)
if aerr != nil {
switch {
case agentCanRelay: // a current Bamboo whose SSH server is not running
problem("That machine's SSH server isn't running. Start Bamboo there with -ssh, and add Hammerhead's public key to authorized_keys.")
default:
problem(aerr.Error() + " Updating Bamboo on that machine lets the terminal work without any port.")
}
return
}
client, err = ssh.Dial("tcp", addr, cfg)
}
if err != nil {
var hk *hostKeyChanged
switch {
case errors.As(err, &hk):
control(termControl{Type: "error", Message: hk.Error(), HostKeyChanged: true})
time.Sleep(200 * time.Millisecond)
case errors.Is(err, errAgentSilent):
problem("The site didn't answer. Is Bamboo running there and connected to Hammerhead?")
case strings.Contains(err.Error(), "unable to authenticate"):
problem("The site refused Hammerhead's key. Add Hammerhead's public key to authorized_keys on that machine.")
default:
problem(fmt.Sprintf("Couldn't reach the site's SSH server: is Bamboo running with -ssh? (%v)", err))
}
return
}
defer client.Close()
sess, err := client.NewSession()
if err != nil {
problem(err.Error())
return
}
defer sess.Close()
if err := sess.RequestPty(termDefaultTerm, rows, cols, ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400}); err != nil {
problem(fmt.Sprintf("The site wouldn't give a terminal: %v", err))
return
}
stdin, err := sess.StdinPipe()
if err != nil {
problem(err.Error())
return
}
stdout, err := sess.StdoutPipe()
if err != nil {
problem(err.Error())
return
}
// What the shell writes to its error output (including why it could not start) goes to the
// terminal like everything else, instead of being thrown away.
sess.Stderr = termWriter(func(p []byte) error { return send(termFrame{data: append([]byte(nil), p...)}) })
if err := sess.Shell(); err != nil {
problem(fmt.Sprintf("Couldn't start a shell: %v", err))
return
}
s.audit(c.Org.ID, c.Actor, "terminal.open", siteName)
started := time.Now()
defer func() {
s.audit(c.Org.ID, c.Actor, "terminal.close", fmt.Sprintf("%s after %s", siteName, time.Since(started).Round(time.Second)))
}()
control(termControl{Type: "ready"})
done := make(chan struct{})
var once sync.Once
stop := func() { once.Do(func() { close(done); ws.Close() }) }
defer stop()
waited := make(chan struct{}) // closed once the browser has been told how the shell ended
go func() { // shell output to the browser
defer func() {
select { // the output ends just before the shell is reaped: let the exit message go out first
case <-waited:
case <-time.After(2 * time.Second):
}
stop()
}()
buf := make([]byte, 16<<10)
for {
n, err := stdout.Read(buf)
if n > 0 {
if send(termFrame{data: append([]byte(nil), buf[:n]...)}) != nil {
return
}
}
if err != nil {
return
}
}
}()
go func() { // the shell ended: say how, then close
code := 0
if err := sess.Wait(); err != nil {
var ee *ssh.ExitError
if errors.As(err, &ee) {
code = ee.ExitStatus()
} else if !errors.Is(err, io.EOF) {
code = -1
}
}
control(termControl{Type: "exit", Code: code})
close(waited)
time.Sleep(200 * time.Millisecond) // let the exit message reach the browser first
stop()
}()
idle := time.AfterFunc(termIdle, func() {
control(termControl{Type: "idle"})
stop()
})
defer idle.Stop()
for { // browser to shell
var f termFrame
if err := frames.Receive(ws, &f); err != nil {
return
}
idle.Reset(termIdle)
if !f.text {
if _, err := stdin.Write(f.data); err != nil {
return
}
continue
}
var ctl termControl
if json.Unmarshal(f.data, &ctl) == nil && ctl.Type == "resize" {
sess.WindowChange(clamp(ctl.Rows, 5, termResizeMaxRow), clamp(ctl.Cols, 20, termResizeMaxCol))
}
}
}
// ---------- the call-back: a terminal without any open port ----------
// termCallBack is how long Hammerhead waits for an agent to answer a terminal request. The agent
// notices the request within a moment, because it is long-polling for its configuration.
const termCallBack = 15 * time.Second
var errAgentSilent = errors.New("the agent did not call back")
// pendingTerm is a terminal session waiting for the site's agent to call back.
type pendingTerm struct {
site string
created time.Time
claimed bool
offered bool // an agent has been sent this session in its configuration
conn chan net.Conn // the agent's call-back connection, once it arrives
done chan struct{} // closed when Hammerhead has finished with that connection
}
// wakeAgentsLocked ends every long poll now, so an agent hears about a change at once.
// The configuration version does not move, so nothing is saved or re-applied.
func (s *Server) wakeAgentsLocked() {
close(s.changed)
s.changed = make(chan struct{})
}
// waitingTerminalsLocked lists the sessions an agent should answer, and forgets stale ones.
func (s *Server) waitingTerminalsLocked(siteID string) []string {
var ids []string
for id, p := range s.terms {
if time.Since(p.created) > 2*termCallBack {
delete(s.terms, id)
continue
}
if p.site == siteID && !p.claimed {
ids = append(ids, id)
p.offered = true
}
}
return ids
}
// newTerminalsLocked reports whether the site has a session its agent has not been told about yet,
// which makes that agent's long poll return straight away instead of waiting for a change.
func (s *Server) newTerminalsLocked(siteID string) bool {
for _, p := range s.terms {
if p.site == siteID && !p.claimed && !p.offered {
return true
}
}
return false
}
// dialViaAgent asks the site's agent to call back and signs in to its SSH server over that
// connection. release must be called when the session is over.
func (s *Server) dialViaAgent(siteID string, cfg *ssh.ClientConfig) (*ssh.Client, func(), error) {
id := randHex(12)
p := &pendingTerm{site: siteID, created: time.Now(), conn: make(chan net.Conn, 1), done: make(chan struct{})}
s.mu.Lock()
s.terms[id] = p
s.wakeAgentsLocked()
s.mu.Unlock()
var once sync.Once
release := func() {
once.Do(func() {
s.mu.Lock()
delete(s.terms, id)
s.mu.Unlock()
close(p.done)
})
}
var conn net.Conn
select {
case conn = <-p.conn:
case <-time.After(termCallBack):
release()
return nil, nil, errAgentSilent
}
conn.SetDeadline(time.Now().Add(termDialTimeout))
cc, chans, reqs, err := ssh.NewClientConn(conn, "site", cfg)
if err != nil {
conn.Close()
release()
return nil, nil, err
}
conn.SetDeadline(time.Time{})
return ssh.NewClient(cc, chans, reqs), release, nil
}
// terminalCallBack is where an agent connects to answer a terminal request. The connection is
// handed to the session that asked for it and carries that session's SSH traffic.
func (s *Server) terminalCallBack(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
st := s.siteByToken(r)
var p *pendingTerm
if st != nil {
if q := s.terms[r.PathValue("id")]; q != nil && q.site == st.ID && !q.claimed {
q.claimed, p = true, q
}
}
s.mu.Unlock()
switch {
case st == nil:
fail(w, 401, errors.New("bad site token"))
return
case p == nil:
fail(w, 404, errors.New("no such terminal"))
return
}
websocket.Server{
Handshake: func(*websocket.Config, *http.Request) error { return nil }, // authenticated by the site token
Handler: func(ws *websocket.Conn) {
ws.PayloadType = websocket.BinaryFrame
ws.MaxPayloadBytes = termMaxFrame
select {
case p.conn <- ws:
<-p.done // the websocket closes when this handler returns
case <-time.After(termCallBack):
}
},
}.ServeHTTP(w, r)
}
// clipText cuts s to at most n bytes.
func clipText(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// termWriter sends what is written to it to the browser as terminal output.
type termWriter func(p []byte) error
func (w termWriter) Write(p []byte) (int, error) {
if err := w(p); err != nil {
return 0, err
}
return len(p), nil
}
+152 -6
View File
@@ -9,6 +9,7 @@
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */ /* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root { :root {
--accent:#1f5c5c; --accent-contrast:#ffffff; --accent:#1f5c5c; --accent-contrast:#ffffff;
--terminal-bg:#0c0c0c; /* a terminal stays dark in both themes */
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8; --bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e; --text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent); --accent-text:var(--accent);
@@ -102,6 +103,8 @@
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); } .grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); } .sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
.scroll { overflow-x:auto; } .scroll { overflow-x:auto; }
td.wrap { overflow-wrap:anywhere; min-width:150px; max-width:260px; }
.ports { display:flex; flex-wrap:wrap; gap:var(--s1); }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; } .actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.form-actions { justify-content:flex-end; margin-top:var(--s2); } .form-actions { justify-content:flex-end; margin-top:var(--s2); }
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; } .toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
@@ -159,6 +162,8 @@
.badge-warning { background:var(--warning-bg); color:var(--warning); } .badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); } .badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); } .badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.term { background:var(--terminal-bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2); height:min(60vh,460px); overflow:hidden; }
.term .xterm-viewport { background-color:var(--terminal-bg) !important; }
.tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); } .tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); }
.tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); } .tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); }
.chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; } .chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; }
@@ -179,7 +184,7 @@
@keyframes shimmer { to { background-position:-200% 0; } } @keyframes shimmer { to { background-position:-200% 0; } }
/* dialogs, tabs, toasts */ /* dialogs, tabs, toasts */
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); } .shade { position:fixed; inset:0; background:var(--overlay); display:grid; grid-template-columns:minmax(0,1fr); place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); } .modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.modal.wide { width:min(780px,100%); } .modal.wide { width:min(780px,100%); }
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); } .modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
@@ -372,6 +377,12 @@ function modal(title, body, opts = {}) {
function onKey(e) { function onKey(e) {
const top = [...document.querySelectorAll('.shade')].pop(); const top = [...document.querySelectorAll('.shade')].pop();
if (top !== shade) return; if (top !== shade) return;
// Inside a terminal every key belongs to the shell (Escape for vim, Tab for completion), except
// Ctrl+Alt+Escape, which moves focus out so the dialog can still be left from the keyboard.
if (opts.keys && e.target.closest && e.target.closest(opts.keys)) {
if (e.key === 'Escape' && e.ctrlKey && e.altKey) { e.preventDefault(); e.stopPropagation(); box.focus(); }
return;
}
if (e.key === 'Escape') { e.stopPropagation(); close(); } if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') { else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; } const f = focusables(); if (!f.length) { e.preventDefault(); return; }
@@ -1028,6 +1039,143 @@ async function shareDialog(m) {
} }
// ----- sites ----- // ----- sites -----
// What an agent reports about its network: Docker containers (-docker) and listening services (-discover).
async function siteFound(s) {
const [list, found] = await Promise.all([api('GET', O(`/sites/${s.id}/containers`)), api('GET', O(`/sites/${s.id}/discovered`))]);
// a published port is "8777→8777"; TCP and UDP on the same port share one entry
const portChips = ports => {
const by = new Map();
for (const p of ports) { const k = (p.public ? p.public + '→' : '') + p.private; by.set(k, [...(by.get(k) || []), p.type]); }
return h('div', {class:'ports'}, [...by].map(([k, types]) => h('span', {class:'tag mono'}, k + '/' + [...new Set(types)].join(','))));
};
const section = (title, count, body, empty) => h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, title + (count ? ` (${count})` : '')), count ? h('div', {class:'scroll'}, body) : h('p', {class:'dim', style:'margin:0'}, empty));
const containers = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(c => h('tr', {}, h('td', {class:'wrap'}, c.name), h('td', {class:'dim wrap'}, c.image), h('td', {}, portChips(c.ports))))));
const services = h('table', {}, h('thead', {}, h('tr', {}, ['Service', 'Address', 'Found', 'Detail'].map(t => h('th', {}, t)))),
h('tbody', {}, found.map(d => h('tr', {}, h('td', {}, d.service || 'tcp'), h('td', {class:'mono small'}, d.host + ':' + d.port),
h('td', {class:'dim'}, d.source === 'local' ? 'Listening here' : 'Network scan'), h('td', {class:'dim small wrap'}, d.detail || '')))));
const close = modal('Found by ' + s.name, h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'These show up as suggestions when you pick a target for a resource. Nothing is shared until you choose it.'),
section('Docker containers', list.length, containers, 'None reported. Start the agent with -docker (BAMBOO_DOCKER=1, or BLACKTIP_DOCKER=1 for an edge) on a machine where Docker is running.'),
section('Services on the network', found.length, services, 'None found. Start the agent with -discover (BAMBOO_DISCOVER=1) for services listening on its machine, and add -discover-subnets (BAMBOO_DISCOVER_SUBNETS) to scan other addresses.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
}
// ----- terminal -----
// A shell on a site, in the dashboard. xterm.js is served by Hammerhead itself and loaded the first time it is needed.
let termLoad = null;
function loadTerm() {
if (!termLoad) {
const script = src => new Promise((ok, bad) => { const el = h('script', {src}); el.onload = ok; el.onerror = () => bad(new Error('Could not load the terminal.')); document.head.append(el); });
document.head.append(h('link', {rel:'stylesheet', href:'/static/term/xterm.css'}));
termLoad = script('/static/term/xterm.js').then(() => script('/static/term/addon-fit.js')).catch(e => { termLoad = null; throw e; });
}
return termLoad;
}
async function openTerminal(s) {
await loadTerm();
const box = h('div', {class:'term', role:'group', 'aria-label':'Terminal on ' + s.name});
const status = h('div', {class:'small dim', role:'status'}, 'Connecting…');
const forget = h('button', {class:'ghost', type:'button', style:'display:none', onclick: act(async () => { await api('DELETE', O(`/sites/${s.id}/terminal/hostkey`)); toast('Saved host key forgotten. Open the terminal again.'); })}, 'Forget saved host key');
const term = new Terminal({cursorBlink:true, fontFamily:'ui-monospace, Menlo, Consolas, monospace', fontSize:14, scrollback:5000,
theme:{background:getComputedStyle(document.documentElement).getPropertyValue('--terminal-bg').trim()}});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
let ws = null, ended = false, ro = null;
const close = modal('Terminal: ' + s.name, h('div', {class:'stack'}, box, status,
h('div', {class:'actions form-actions'}, forget, h('button', {class:'btn', onclick:() => close()}, 'Close'))),
{sticky:true, wide:true, keys:'.term', onClose:() => { ended = true; if (ro) ro.disconnect(); if (ws) ws.close(); term.dispose(); }});
term.open(box); fit.fit(); term.focus();
const say = (text, bad) => { status.textContent = text; status.className = 'small ' + (bad ? 'err' : 'dim'); };
ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${O(`/sites/${s.id}/terminal`)}?cols=${term.cols}&rows=${term.rows}`);
ws.binaryType = 'arraybuffer';
ws.onmessage = ev => {
if (typeof ev.data !== 'string') { term.write(new Uint8Array(ev.data)); return; } // the shell's output
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.type === 'ready') say('Connected. Esc and Tab go to the shell. Press Ctrl+Alt+Esc to move focus out of the terminal.');
else if (m.type === 'error') { say(m.message, true); if (m.hostKeyChanged) forget.style.display = ''; }
else if (m.type === 'exit') say('The shell ended' + (m.code ? ` (exit code ${m.code})` : '') + '.');
else if (m.type === 'idle') say('Closed after 30 minutes without typing.', true);
};
ws.onclose = () => {
if (ended) return;
if (status.textContent === 'Connecting…') say('Could not open the terminal. Sign in again and retry.', true);
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
};
const enc = new TextEncoder();
term.onData(d => { if (ws.readyState === 1) ws.send(enc.encode(d)); });
term.onBinary(d => { if (ws.readyState === 1) ws.send(Uint8Array.from(d, c => c.charCodeAt(0))); });
ro = new ResizeObserver(() => { fit.fit(); if (ws.readyState === 1) ws.send(JSON.stringify({type:'resize', cols:term.cols, rows:term.rows})); });
ro.observe(box);
}
// Bamboo's built-in SSH server (started with -ssh) listens here on the site by default.
const SSH_TARGET = '127.0.0.1:2222';
// Reach a site's Bamboo SSH server with an ordinary ssh client: a TCP port on an edge whose target is that server.
async function sshAccess(s) {
const st = S.st, key = await api('GET', O('/terminal'));
const [tHost, tPort] = [SSH_TARGET.split(':')[0], +SSH_TARGET.split(':')[1]];
const isSSH = m => m.proto === 'tcp' && !m.redirectTo && m.targets.some(t => t.siteId === s.id && (t.port === tPort || (m.tags || []).includes('ssh')));
let ports = st.mappings.filter(isSSH);
// what to type after the @: a domain pointed at the address when there is one, else the address itself
const edgeHost = (e, m) => {
if (m.bindIp && m.bindIp !== '0.0.0.0') { const d = st.domains.find(x => x.ip === m.bindIp); return d ? d.name : m.bindIp; }
const d = st.domains.find(x => x.ip && (!x.edgeId || x.edgeId === m.edgeId));
return d ? d.name : ((e && e.ips) || [])[0] || 'your-edge-address';
};
const cmds = h('div', {class:'stack'});
const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.'));
// Bamboo reports its SSH server; when it does, the terminal reaches it through the agent and needs no port
const viaAgent = !!(s.sshRelay && s.ssh);
const termBtn = h('button', {class:'btn', type:'button', onclick: act(async () => { close(); await openTerminal(s); })}, 'Open terminal');
const termNote = h('p', {class:'dim small', style:'margin:0'});
const draw = () => {
connectBox.style.display = ports.length ? '' : 'none';
termBtn.disabled = !viaAgent && !ports.length;
termNote.textContent = viaAgent ? 'No port needs to be open: Bamboo on the site connects out to Hammerhead for the session.'
: ports.length ? 'This uses the SSH port below. Updating Bamboo or turning on its SSH server makes a port unnecessary.'
: 'Needs Bamboo\'s SSH server to be running (see above), or an SSH port below.';
cmds.replaceChildren(...ports.map(m => {
const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), m)}`;
return h('div', {}, h('div', {class:'small dim'}, m.name + (m.enabled ? '' : ' (paused)')), cmdBox(cmd),
h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(cmd); toast('Copied'); })}, 'Copy'));
}));
};
draw();
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name + (e.online ? '' : ' (offline)'))));
const ipSel = h('select', {});
const refreshIPs = () => { const e = byId(st.edges, edgeSel.value); ipSel.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...((e && e.ips) || []).map(ip => h('option', {value:ip}, ip))); };
edgeSel.onchange = refreshIPs; refreshIPs();
const port = h('input', {type:'number', min:1, max:65535, value:2222, placeholder:'2222'});
const target = h('input', {value:SSH_TARGET});
const create = act(async () => {
const [h2, p2] = target.value.trim().split(':');
if (!st.edges.length) throw new Error('Add an edge first');
const m = await api('POST', O('/mappings'), {name:'SSH to ' + s.name, proto:'tcp', edgeId:edgeSel.value, bindIp:ipSel.value || '0.0.0.0', port:+port.value, tags:['ssh'], enabled:true,
targets:[{siteId:s.id, host:h2 || tHost, port:+p2 || tPort}]});
ports = [...ports, m]; draw(); toast('SSH port opened'); refresh();
});
const close = modal('SSH into ' + s.name, h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Bamboo has a small SSH server, so you can open a shell on this machine without installing sshd. Start Bamboo with -ssh (BAMBOO_SSH=1) and put the public keys that may sign in in authorized_keys in its data folder. Only keys are accepted, never passwords.'),
h('div', {style:'display:flex;gap:var(--s2);align-items:center;flex-wrap:wrap'},
s.sshRelay ? (s.ssh ? badge('SSH server running', 'success') : badge('SSH server off', 'warning')) : badge('Bamboo needs an update', 'warning'),
h('span', {class:'small dim'}, s.sshRelay ? (s.ssh ? 'Listening on ' + s.ssh + ' on that machine.' : 'Start Bamboo there with -ssh.') : 'This Bamboo is too old to report its SSH server.')),
h('fieldset', {}, h('legend', {}, 'Terminal in Hammerhead'), h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Open a shell here in the dashboard. Hammerhead signs in with a key of its own, so add this line to authorized_keys on the site first (Bamboo picks it up without a restart).'),
cmdBox(key.publicKey),
termNote,
h('div', {class:'actions'}, h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(key.publicKey); toast('Copied'); })}, 'Copy key'), termBtn))),
connectBox,
h('fieldset', {}, h('legend', {}, 'Use an ordinary ssh client (optional)'), h('div', {class:'stack'},
h('p', {class:'dim small', style:'margin:0'}, 'Only needed to connect from your own computer. The terminal above does not use this port.'),
h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipSel), field('Public port', port, 'Anyone can reach it, but only listed keys sign in')),
field('Bamboo\'s SSH server on the site', target, 'Leave as is unless you changed -ssh-listen'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', type:'button', onclick:create}, 'Open SSH port')))),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
}
views.sites = async () => { views.sites = async () => {
const st = S.st, name = h('input', {placeholder:'e.g. Home lab'}); const st = S.st, name = h('input', {placeholder:'e.g. Home lab'});
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); }); const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
@@ -1047,17 +1195,15 @@ views.sites = async () => {
}; };
return h('div', {}, return h('div', {},
tagBar('sites', tags), tagBar('sites', tags),
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))), card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Discovered', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {}, h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {},
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)), h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)),
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'), h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')), h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)), s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => { h('td', {}, h('button', {class:'ghost', onclick: act(() => siteFound(s))}, 'View')),
const l = await api('GET', O(`/sites/${s.id}/containers`));
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
h('td', {}, s.local ? null : h('div', {class:'actions'}, h('td', {}, s.local ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(() => sshAccess(s))}, 'SSH'),
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'), h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'), h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete'))))))))); h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+3
View File
@@ -0,0 +1,3 @@
The web terminal uses xterm.js 6.0.0 and its fit addon 0.11.0 (MIT license, see LICENSE-xterm.txt),
copied from the npm packages @xterm/xterm and @xterm/addon-fit. The only change is that the trailing
source map comment is removed, because the map files are not shipped.
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
+285
View File
@@ -0,0 +1,285 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
font-family: monospace;
user-select: text;
white-space: pre;
}
.xterm .xterm-accessibility-tree > div {
transform-origin: left;
width: fit-content;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
/* xterm.js customization: Override xterm's cursor style */
.xterm .xterm-scrollable-element > .scrollbar {
cursor: default;
}
/* Arrows */
.xterm .xterm-scrollable-element > .scrollbar > .scra {
cursor: pointer;
font-size: 11px !important;
}
.xterm .xterm-scrollable-element > .visible {
opacity: 1;
/* Background rule added for IE9 - to allow clicks on dom node */
background:rgba(0,0,0,0);
transition: opacity 100ms linear;
/* In front of peek view */
z-index: 11;
}
.xterm .xterm-scrollable-element > .invisible {
opacity: 0;
pointer-events: none;
}
.xterm .xterm-scrollable-element > .invisible.fade {
transition: opacity 800ms linear;
}
/* Scrollable Content Inset Shadow */
.xterm .xterm-scrollable-element > .shadow {
position: absolute;
display: none;
}
.xterm .xterm-scrollable-element > .shadow.top {
display: block;
top: 0;
left: 3px;
height: 3px;
width: 100%;
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.left {
display: block;
top: 3px;
left: 0;
height: 100%;
width: 3px;
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
display: block;
top: 0;
left: 0;
height: 3px;
width: 3px;
}
.xterm .xterm-scrollable-element > .shadow.top.left {
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
+1
View File
@@ -0,0 +1 @@
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={2840:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(7721),o=i(4292),a=i(7150),l=i(7098),h=i(6501),c=i(7093);let d=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";const r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this._register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this._register(this._terminal.onScroll((()=>this._refreshRows()))),this._register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this._register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this._register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this._register(this._terminal.onKey((e=>this._handleKey(e.key)))),this._register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this._register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._register((0,c.addDisposableListener)(r,"selectionchange",(()=>this._handleSelectionChange()))),this._register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeLine truncated