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>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 01:02:28 -06:00
1 parent 1c1f2b1d44
commit 66483d8385
14 files changed
+1012 -5

No files matched your search

+8
View File
@@ -163,6 +163,14 @@ 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
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.
- **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 opens a TCP port on an edge to that
server and shows the `ssh` command to use. The same dialog shows Hammerhead's own public key: add it to
`authorized_keys` and **Open terminal** gives an administrator a shell in the browser. Hammerhead is the
SSH client (it signs in through that SSH port), 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.
- **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
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
+2 -1
View File
@@ -128,6 +128,7 @@ func (d *DB) sealed(v any) any {
case Settings:
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
x.Sandbar.Token = b.Seal(x.Sandbar.Token)
x.Terminal.Key = b.Seal(x.Terminal.Key)
return x
}
return v
@@ -157,7 +158,7 @@ func (d *DB) unseal(v any) error {
case *AlertChannel:
return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
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
}
+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)
// asking before any SSH port exists says what to do
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "SSH port") {
t.Errorf("before opening an SSH port: %+v", m)
}
// Hammerhead's public key, which goes in the site's authorized_keys
var key struct{ PublicKey, Fingerprint string }
w.admin.must("GET", base+"/terminal", nil, &key)
if !strings.HasPrefix(key.PublicKey, "ssh-ed25519 ") || strings.Contains(key.PublicKey, "PRIVATE") {
t.Fatalf("public key: %q", key.PublicKey)
}
var again struct{ PublicKey string }
w.admin.must("GET", base+"/terminal", nil, &again)
if again.PublicKey != key.PublicKey {
t.Fatal("the key changed between requests")
}
dir := t.TempDir()
keys := filepath.Join(dir, "authorized_keys")
if err := os.WriteFile(keys, []byte(key.PublicKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
srv, err := sshd.New(sshd.Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "ssh_host_ed25519_key")})
if err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go srv.Serve(ctx, ln)
edgePort := freePort(t)
w.admin.must("POST", base+"/mappings", map[string]any{"name": "ssh", "proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": edgePort,
"enabled": true, "tags": []string{"ssh"}, "targets": tgt(w.site, ln.Addr().(*net.TCPAddr).Port)}, nil)
// wait for the path through the tunnel to come up
var m termMsg
for i := 0; i < 60; i++ {
if m = first(); !strings.Contains(m.Message, "reach the site") {
break
}
time.Sleep(250 * time.Millisecond)
}
if sshd.Supported {
if m.Type != "ready" {
t.Fatalf("opening the terminal: %+v", m)
}
} else if m.Type != "error" || !strings.Contains(m.Message, "terminal") {
// this platform can't run shells, but asking for one proves Hammerhead's key was accepted
t.Fatalf("on a platform without shells: %+v", m)
}
if sshd.Supported {
ws, err := dial(w.ts.URL, true)
if err != nil {
t.Fatal(err)
}
defer ws.Close()
if m := control(ws); m.Type != "ready" {
t.Fatalf("second terminal: %+v", m)
}
// $((40+2)) is expanded by the shell, so "terminal-42" can only come from the command running
if err := websocket.Message.Send(ws, []byte("echo terminal-$((40+2))\n")); err != nil {
t.Fatal(err)
}
var out strings.Builder
for !strings.Contains(out.String(), "terminal-42\r\n") {
ws.SetReadDeadline(time.Now().Add(10 * time.Second))
var f []byte
if err := websocket.Message.Receive(ws, &f); err != nil {
t.Fatalf("waiting for the command's output (got %q): %v", out.String(), err)
}
out.Write(f)
}
websocket.Message.Send(ws, `{"type":"resize","cols":120,"rows":40}`)
websocket.Message.Send(ws, []byte("exit\n"))
var sawExit bool
for !sawExit {
ws.SetReadDeadline(time.Now().Add(10 * time.Second))
var f []byte
if err := websocket.Message.Receive(ws, &f); err != nil {
break
}
sawExit = bytes.Contains(f, []byte(`"type":"exit"`))
}
if !sawExit {
t.Error("the browser was not told the shell ended")
}
}
// taking Hammerhead's key out of authorized_keys is refused with a message that says what to fix
other := newSSHSigner(t)
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(other.PublicKey()), 0o600); err != nil {
t.Fatal(err)
}
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "authorized_keys") {
t.Errorf("after removing the key: %+v", m)
}
// a site whose SSH server has stopped is reported, not left hanging
cancel()
ln.Close()
time.Sleep(300 * time.Millisecond)
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "reach the site") {
t.Errorf("with the SSH server stopped: %+v", m)
}
}
+1 -1
View File
@@ -8,6 +8,7 @@ require (
git.veillax.com/Veillax/reef v0.1.11
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0
modernc.org/sqlite v1.61.0
)
@@ -20,7 +21,6 @@ require (
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // 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/text v0.43.0 // indirect
golang.org/x/time v0.7.0 // indirect
+4
View File
@@ -45,6 +45,10 @@ func (s *Server) Handler() http.Handler {
s.registerAlertRoutes(mux)
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
mux.HandleFunc("GET /api/public", s.publicInfo)
+2
View File
@@ -18,6 +18,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *principa
st := s.st.Settings
st.SMTP.Pass = "" // 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})
}
@@ -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.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.bump() // edges receive the dashboard URL and ACME settings
s.mu.Unlock()
+2
View File
@@ -120,6 +120,8 @@ type Settings struct {
Sandbar SandbarSync `json:"sandbar"`
// SelfEdgeID is this server's own edge (see HAMMERHEAD_SELF_DOMAIN).
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 {
+418
View File
@@ -0,0 +1,418 @@
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()
addr, err := s.terminalAddr(c, siteID)
if err != nil {
problem(err.Error())
return
}
s.mu.Lock()
signer, err := s.terminalSignerLocked()
siteName := ""
if st := s.findSite(siteID); st != nil {
siteName = st.Name
}
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
}
client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: s.trustHostKey(siteID, siteName), Timeout: termDialTimeout})
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 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
}
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))
}
}
}
+68 -3
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. */
:root {
--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;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
@@ -161,6 +162,8 @@
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.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); }
.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; }
@@ -374,6 +377,12 @@ function modal(title, body, opts = {}) {
function onKey(e) {
const top = [...document.querySelectorAll('.shade')].pop();
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(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
@@ -1052,12 +1061,61 @@ async function siteFound(s) {
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.
function sshAccess(s) {
const st = S.st;
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);
@@ -1069,8 +1127,11 @@ function sshAccess(s) {
};
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.'));
const termBtn = h('button', {class:'btn', type:'button', onclick: act(async () => { close(); await openTerminal(s); })}, 'Open terminal');
const draw = () => {
connectBox.style.display = ports.length ? '' : 'none';
termBtn.disabled = !ports.length;
termBtn.title = ports.length ? '' : 'Open an SSH port first';
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),
@@ -1094,6 +1155,10 @@ function sshAccess(s) {
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 from anywhere 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.'),
connectBox,
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),
h('div', {class:'actions'}, h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(key.publicKey); toast('Copied'); })}, 'Copy key'), termBtn))),
h('fieldset', {}, h('legend', {}, 'Open an SSH port'), h('div', {class:'stack'},
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'),
@@ -1128,7 +1193,7 @@ views.sites = async () => {
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(() => siteFound(s))}, 'View')),
h('td', {}, s.local ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => sshAccess(s)}, 'SSH'),
h('button', {class:'ghost', onclick: act(() => sshAccess(s))}, 'SSH'),
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
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')))))))));
+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