Web terminal: open a shell on a site from the dashboard
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:
1 parent
1c1f2b1d44
commit
66483d8385
14 files changed
+1012
-5
No files matched your search
@@ -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
|
- **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 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
|
- **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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
git.veillax.com/Veillax/reef v0.1.11
|
git.veillax.com/Veillax/reef v0.1.11
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +21,6 @@ require (
|
|||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+418
@@ -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
@@ -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);
|
||||||
@@ -161,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; }
|
||||||
@@ -374,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; }
|
||||||
@@ -1052,12 +1061,61 @@ async function siteFound(s) {
|
|||||||
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
|
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.
|
// Bamboo's built-in SSH server (started with -ssh) listens here on the site by default.
|
||||||
const SSH_TARGET = '127.0.0.1:2222';
|
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.
|
// 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) {
|
async function sshAccess(s) {
|
||||||
const st = S.st;
|
const st = S.st, key = await api('GET', O('/terminal'));
|
||||||
const [tHost, tPort] = [SSH_TARGET.split(':')[0], +SSH_TARGET.split(':')[1]];
|
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')));
|
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);
|
let ports = st.mappings.filter(isSSH);
|
||||||
@@ -1069,8 +1127,11 @@ function sshAccess(s) {
|
|||||||
};
|
};
|
||||||
const cmds = h('div', {class:'stack'});
|
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 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 = () => {
|
const draw = () => {
|
||||||
connectBox.style.display = ports.length ? '' : 'none';
|
connectBox.style.display = ports.length ? '' : 'none';
|
||||||
|
termBtn.disabled = !ports.length;
|
||||||
|
termBtn.title = ports.length ? '' : 'Open an SSH port first';
|
||||||
cmds.replaceChildren(...ports.map(m => {
|
cmds.replaceChildren(...ports.map(m => {
|
||||||
const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), 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),
|
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'},
|
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.'),
|
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,
|
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('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')),
|
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'),
|
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`)),
|
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', {}, h('button', {class:'ghost', onclick: act(() => siteFound(s))}, 'View')),
|
||||||
h('td', {}, s.local ? null : h('div', {class:'actions'},
|
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:() => 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')))))))));
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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})()));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user