Compare commits
4
Commits
0f17659962
...
v0.1.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35d4000bc4 | ||
|
|
66483d8385 | ||
|
|
1c1f2b1d44 | ||
|
|
92262441db |
No files matched your search
@@ -163,6 +163,17 @@ for each provider (e.g. `CF_DNS_API_TOKEN` for `cloudflare`); `.env` is the plac
|
|||||||
- **Port ranges** for tcp and udp: `2456-2458` opens all three, each sent to the target port plus the
|
- **Port ranges** for tcp and udp: `2456-2458` opens all three, each sent to the target port plus the
|
||||||
same offset (up to 1000 ports per entry; a busy port inside a range is named in the status).
|
same offset (up to 1000 ports per entry; a busy port inside a range is named in the status).
|
||||||
- TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
|
- TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
|
||||||
|
- **SSH into a site, and a terminal in the dashboard.** Start Bamboo with `-ssh` and list the allowed public
|
||||||
|
keys in its `authorized_keys`. The **SSH** button on the Sites page shows Hammerhead's own public key: add
|
||||||
|
it to `authorized_keys` and **Open terminal** gives an administrator a shell in the browser. No port has to
|
||||||
|
be open anywhere: Bamboo reports its SSH server, and when a terminal is opened it calls back to Hammerhead
|
||||||
|
and joins that connection to the server. Hammerhead is the SSH client, saves each site's host key the
|
||||||
|
first time and refuses a different one later (with a button to forget it), allows a terminal only from the
|
||||||
|
dashboard's own origin, logs opening and closing in the audit log, and closes an idle one after 30
|
||||||
|
minutes. The shell runs as the user Bamboo runs as, so treat Hammerhead's key like a root login if Bamboo
|
||||||
|
runs as root. To connect from your own computer with an ordinary `ssh` client, the same dialog can open a
|
||||||
|
TCP port on an edge to that server and shows the command to use. An older Bamboo that does not report its
|
||||||
|
SSH server can still be reached through such a port.
|
||||||
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
|
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
|
||||||
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
|
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
|
||||||
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
|
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
|
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
|
||||||
if !s.wait(r, have) {
|
if !s.waitUntil(r, have, func() bool { return s.newTerminalsLocked(st.ID) }) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if st := s.siteByToken(r); st != nil {
|
if st := s.siteByToken(r); st != nil {
|
||||||
st.LastSeen = time.Now()
|
st.LastSeen = time.Now()
|
||||||
@@ -235,6 +235,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
|
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cfg.Terminals = s.waitingTerminalsLocked(st.ID)
|
||||||
writeJSON(w, 200, cfg)
|
writeJSON(w, 200, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +261,7 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
|
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
|
||||||
s.containers[st.ID] = in.Containers
|
s.containers[st.ID] = in.Containers
|
||||||
s.discovered[st.ID] = cleanDiscovered(in.Discovered)
|
s.discovered[st.ID] = cleanDiscovered(in.Discovered)
|
||||||
|
st.SSH, st.SSHRelay = clipText(in.SSH, 100), in.SSHRelay
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.SiteID != st.ID {
|
if x.SiteID != st.ID {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ func (d *DB) sealed(v any) any {
|
|||||||
case Settings:
|
case Settings:
|
||||||
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
|
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
|
||||||
x.Sandbar.Token = b.Seal(x.Sandbar.Token)
|
x.Sandbar.Token = b.Seal(x.Sandbar.Token)
|
||||||
|
x.Terminal.Key = b.Seal(x.Terminal.Key)
|
||||||
return x
|
return x
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
@@ -157,7 +158,7 @@ func (d *DB) unseal(v any) error {
|
|||||||
case *AlertChannel:
|
case *AlertChannel:
|
||||||
return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
|
return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
|
||||||
case *Settings:
|
case *Settings:
|
||||||
return errors.Join(d.open1(&x.SMTP.Pass), d.open1(&x.Sandbar.Token))
|
return errors.Join(d.open1(&x.SMTP.Pass), d.open1(&x.Sandbar.Token), d.open1(&x.Terminal.Key))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/bamboo/sshd"
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSSHSigner(t *testing.T) ssh.Signer {
|
||||||
|
t.Helper()
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := ssh.NewSignerFromKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// A TCP port on the edge whose target is Bamboo's SSH server at the site reaches that server
|
||||||
|
// with an ordinary ssh client: the path is client, edge, WireGuard tunnel, Bamboo, sshd.
|
||||||
|
func TestSSHThroughTheEdgeToBamboosServer(t *testing.T) {
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
dir := t.TempDir()
|
||||||
|
signer := newSSHSigner(t)
|
||||||
|
keys := filepath.Join(dir, "authorized_keys")
|
||||||
|
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(signer.PublicKey()), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv, err := sshd.New(sshd.Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "ssh_host_ed25519_key")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
go srv.Serve(ctx, ln)
|
||||||
|
|
||||||
|
edgePort := freePort(t)
|
||||||
|
w.open(model.Mapping{Proto: model.ProtoTCP, Port: edgePort, Name: "ssh", Targets: tgt(w.site, ln.Addr().(*net.TCPAddr).Port)})
|
||||||
|
addr := "127.0.0.1:" + strconv.Itoa(edgePort)
|
||||||
|
|
||||||
|
connect := func(auth ssh.AuthMethod) (*ssh.Client, error) {
|
||||||
|
return ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "alex", Auth: []ssh.AuthMethod{auth}, Timeout: 5 * time.Second,
|
||||||
|
// the key the client sees through the tunnel must be Bamboo's, not something in between
|
||||||
|
HostKeyCallback: func(_ string, _ net.Addr, k ssh.PublicKey) error {
|
||||||
|
if got := ssh.FingerprintSHA256(k); got != srv.Fingerprint() {
|
||||||
|
return fmt.Errorf("host key %s is not the Bamboo server's %s", got, srv.Fingerprint())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
var client *ssh.Client
|
||||||
|
var last error
|
||||||
|
for i := 0; i < 60 && client == nil; i++ {
|
||||||
|
if client, last = connect(ssh.PublicKeys(signer)); client == nil {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
t.Fatalf("could not sign in through the edge: %v", last)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if sshd.Supported {
|
||||||
|
sess, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := sess.Output("echo through-the-tunnel")
|
||||||
|
sess.Close()
|
||||||
|
if err != nil || strings.TrimSpace(string(out)) != "through-the-tunnel" {
|
||||||
|
t.Fatalf("running a command: %q, %v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the same narrow rules hold through the tunnel
|
||||||
|
if c, err := connect(ssh.PublicKeys(newSSHSigner(t))); err == nil {
|
||||||
|
c.Close()
|
||||||
|
t.Error("a key that is not in authorized_keys signed in")
|
||||||
|
}
|
||||||
|
if c, err := connect(ssh.Password("hunter2")); err == nil {
|
||||||
|
c.Close()
|
||||||
|
t.Error("a password signed in")
|
||||||
|
}
|
||||||
|
if c, err := client.Dial("tcp", "127.0.0.1:1"); err == nil {
|
||||||
|
c.Close()
|
||||||
|
t.Error("port forwarding through the SSH server is allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/bamboo"
|
||||||
|
"git.veillax.com/Veillax/bamboo/sshd"
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// With Bamboo reporting its SSH server, the terminal works with no SSH port anywhere: Hammerhead
|
||||||
|
// asks the agent to call back and signs in over that connection.
|
||||||
|
func TestWebTerminalWithoutAnyOpenPort(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
siteCfgHook = func(c *bamboo.Config) { c.SSH = ln.Addr().String() } // what bamboo -ssh tells the agent
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
siteCfgHook = nil
|
||||||
|
base := "/api/orgs/" + w.org
|
||||||
|
u, _ := url.Parse(w.ts.URL)
|
||||||
|
|
||||||
|
var key struct{ PublicKey string }
|
||||||
|
w.admin.must("GET", base+"/terminal", nil, &key)
|
||||||
|
dir := t.TempDir()
|
||||||
|
keys := filepath.Join(dir, "authorized_keys")
|
||||||
|
if err := os.WriteFile(keys, []byte(key.PublicKey+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv, err := sshd.New(sshd.Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "ssh_host_ed25519_key")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
go srv.Serve(ctx, ln)
|
||||||
|
|
||||||
|
// the agent's next report tells Hammerhead where its SSH server is
|
||||||
|
var st struct{ Sites []model.Site }
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
w.admin.must("GET", base+"/state", nil, &st)
|
||||||
|
for _, s := range st.Sites {
|
||||||
|
if s.ID == w.site && s.SSH != "" {
|
||||||
|
i = 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < 60 {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var site model.Site
|
||||||
|
for _, s := range st.Sites {
|
||||||
|
if s.ID == w.site {
|
||||||
|
site = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if site.SSH != ln.Addr().String() || !site.SSHRelay {
|
||||||
|
t.Fatalf("the site did not report its SSH server: %+v", site)
|
||||||
|
}
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(w.ts.URL, "http") + base + "/sites/" + w.site + "/terminal?cols=100&rows=30"
|
||||||
|
dial := func() *websocket.Conn {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := websocket.NewConfig(wsURL, w.ts.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ck []string
|
||||||
|
for _, c := range w.admin.c.Jar.Cookies(u) {
|
||||||
|
ck = append(ck, c.Name+"="+c.Value)
|
||||||
|
}
|
||||||
|
cfg.Header.Set("Cookie", strings.Join(ck, "; "))
|
||||||
|
ws, err := websocket.DialConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
first := func() (m termMsg) {
|
||||||
|
t.Helper()
|
||||||
|
ws := dial()
|
||||||
|
defer ws.Close()
|
||||||
|
for {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(20 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
t.Fatalf("waiting for the terminal: %v", err)
|
||||||
|
}
|
||||||
|
if len(f) > 0 && f[0] == '{' {
|
||||||
|
return parseTermMsg(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := first()
|
||||||
|
if sshd.Supported {
|
||||||
|
if m.Type != "ready" {
|
||||||
|
t.Fatalf("opening the terminal with no port: %+v", m)
|
||||||
|
}
|
||||||
|
ws := dial()
|
||||||
|
defer ws.Close()
|
||||||
|
// $((40+2)) is expanded by the shell, so "relay-42" can only come from the command running
|
||||||
|
if err := websocket.Message.Send(ws, []byte("echo relay-$((40+2))\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
for !strings.Contains(out.String(), "relay-42\r\n") {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
t.Fatalf("waiting for the command's output (got %q): %v", out.String(), err)
|
||||||
|
}
|
||||||
|
out.Write(f)
|
||||||
|
}
|
||||||
|
} else if m.Type != "error" || !strings.Contains(m.Message, "terminal") {
|
||||||
|
// this platform can't run shells, but asking for one proves the call-back and Hammerhead's key worked
|
||||||
|
t.Fatalf("with no port, on a platform without shells: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// it really did not need a port
|
||||||
|
var maps struct{ Mappings []model.Mapping }
|
||||||
|
w.admin.must("GET", base+"/state", nil, &maps)
|
||||||
|
if len(maps.Mappings) != 0 {
|
||||||
|
t.Errorf("the test opened a port after all: %+v", maps.Mappings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the SSH server still decides who gets in
|
||||||
|
other := newSSHSigner(t)
|
||||||
|
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(other.PublicKey()), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "authorized_keys") {
|
||||||
|
t.Errorf("after removing Hammerhead's key: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// and a stopped SSH server is reported at once rather than after a timeout
|
||||||
|
cancel()
|
||||||
|
ln.Close()
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
start := time.Now()
|
||||||
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "SSH server") {
|
||||||
|
t.Errorf("with the SSH server stopped: %+v", m)
|
||||||
|
}
|
||||||
|
if time.Since(start) > 8*time.Second {
|
||||||
|
t.Errorf("a stopped SSH server took %s to be reported", time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTermMsg(b []byte) termMsg {
|
||||||
|
var m termMsg
|
||||||
|
json.Unmarshal(b, &m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/bamboo/sshd"
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type termMsg struct {
|
||||||
|
Type, Message string
|
||||||
|
Code int
|
||||||
|
HostKeyChanged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dashboard's terminal: browser, Hammerhead (as the SSH client), the SSH port on the edge,
|
||||||
|
// the WireGuard tunnel, Bamboo, and Bamboo's SSH server.
|
||||||
|
func TestWebTerminalThroughTheEdgeToBamboosServer(t *testing.T) {
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
base := "/api/orgs/" + w.org
|
||||||
|
u, _ := url.Parse(w.ts.URL)
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(w.ts.URL, "http") + base + "/sites/" + w.site + "/terminal?cols=100&rows=30"
|
||||||
|
|
||||||
|
// dial opens the terminal's WebSocket as the administrator, or as nobody
|
||||||
|
dial := func(origin string, signedIn bool) (*websocket.Conn, error) {
|
||||||
|
cfg, err := websocket.NewConfig(wsURL, origin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if signedIn {
|
||||||
|
var ck []string
|
||||||
|
for _, c := range w.admin.c.Jar.Cookies(u) {
|
||||||
|
ck = append(ck, c.Name+"="+c.Value)
|
||||||
|
}
|
||||||
|
cfg.Header.Set("Cookie", strings.Join(ck, "; "))
|
||||||
|
}
|
||||||
|
return websocket.DialConfig(cfg)
|
||||||
|
}
|
||||||
|
// control reads until the next text frame: the terminal's own messages ("ready", "error", "exit")
|
||||||
|
control := func(ws *websocket.Conn) termMsg {
|
||||||
|
t.Helper()
|
||||||
|
for {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
t.Fatalf("waiting for the terminal: %v", err)
|
||||||
|
}
|
||||||
|
var m termMsg
|
||||||
|
if json.Unmarshal(f, &m) == nil && m.Type != "" && f[0] == '{' {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// first says how a new terminal begins
|
||||||
|
first := func() termMsg {
|
||||||
|
t.Helper()
|
||||||
|
ws, err := dial(w.ts.URL, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
return control(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nobody signed in, or a page on another site, cannot open it
|
||||||
|
if ws, err := dial(w.ts.URL, false); err == nil {
|
||||||
|
ws.Close()
|
||||||
|
t.Error("the terminal opened for someone who is not signed in")
|
||||||
|
}
|
||||||
|
if ws, err := dial("https://evil.example", true); err == nil {
|
||||||
|
ws.Close()
|
||||||
|
t.Error("a page on another site opened the terminal with the administrator's cookie")
|
||||||
|
}
|
||||||
|
anon := newClient(t, w.ts.URL)
|
||||||
|
anon.expect(401, "GET", base+"/terminal", nil)
|
||||||
|
|
||||||
|
// this Bamboo has no SSH server of its own to call back to, so before a port exists the message says to start it
|
||||||
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "-ssh") {
|
||||||
|
t.Errorf("before opening an SSH port: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hammerhead's public key, which goes in the site's authorized_keys
|
||||||
|
var key struct{ PublicKey, Fingerprint string }
|
||||||
|
w.admin.must("GET", base+"/terminal", nil, &key)
|
||||||
|
if !strings.HasPrefix(key.PublicKey, "ssh-ed25519 ") || strings.Contains(key.PublicKey, "PRIVATE") {
|
||||||
|
t.Fatalf("public key: %q", key.PublicKey)
|
||||||
|
}
|
||||||
|
var again struct{ PublicKey string }
|
||||||
|
w.admin.must("GET", base+"/terminal", nil, &again)
|
||||||
|
if again.PublicKey != key.PublicKey {
|
||||||
|
t.Fatal("the key changed between requests")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
keys := filepath.Join(dir, "authorized_keys")
|
||||||
|
if err := os.WriteFile(keys, []byte(key.PublicKey+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv, err := sshd.New(sshd.Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "ssh_host_ed25519_key")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
go srv.Serve(ctx, ln)
|
||||||
|
|
||||||
|
edgePort := freePort(t)
|
||||||
|
w.admin.must("POST", base+"/mappings", map[string]any{"name": "ssh", "proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": edgePort,
|
||||||
|
"enabled": true, "tags": []string{"ssh"}, "targets": tgt(w.site, ln.Addr().(*net.TCPAddr).Port)}, nil)
|
||||||
|
|
||||||
|
// wait for the path through the tunnel to come up
|
||||||
|
var m termMsg
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
if m = first(); !strings.Contains(m.Message, "reach the site") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if sshd.Supported {
|
||||||
|
if m.Type != "ready" {
|
||||||
|
t.Fatalf("opening the terminal: %+v", m)
|
||||||
|
}
|
||||||
|
} else if m.Type != "error" || !strings.Contains(m.Message, "terminal") {
|
||||||
|
// this platform can't run shells, but asking for one proves Hammerhead's key was accepted
|
||||||
|
t.Fatalf("on a platform without shells: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sshd.Supported {
|
||||||
|
ws, err := dial(w.ts.URL, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
if m := control(ws); m.Type != "ready" {
|
||||||
|
t.Fatalf("second terminal: %+v", m)
|
||||||
|
}
|
||||||
|
// $((40+2)) is expanded by the shell, so "terminal-42" can only come from the command running
|
||||||
|
if err := websocket.Message.Send(ws, []byte("echo terminal-$((40+2))\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
for !strings.Contains(out.String(), "terminal-42\r\n") {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
t.Fatalf("waiting for the command's output (got %q): %v", out.String(), err)
|
||||||
|
}
|
||||||
|
out.Write(f)
|
||||||
|
}
|
||||||
|
websocket.Message.Send(ws, `{"type":"resize","cols":120,"rows":40}`)
|
||||||
|
websocket.Message.Send(ws, []byte("exit\n"))
|
||||||
|
var sawExit bool
|
||||||
|
for !sawExit {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sawExit = bytes.Contains(f, []byte(`"type":"exit"`))
|
||||||
|
}
|
||||||
|
if !sawExit {
|
||||||
|
t.Error("the browser was not told the shell ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// taking Hammerhead's key out of authorized_keys is refused with a message that says what to fix
|
||||||
|
other := newSSHSigner(t)
|
||||||
|
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(other.PublicKey()), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "authorized_keys") {
|
||||||
|
t.Errorf("after removing the key: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a site whose SSH server has stopped is reported, not left hanging
|
||||||
|
cancel()
|
||||||
|
ln.Close()
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "reach the site") {
|
||||||
|
t.Errorf("with the SSH server stopped: %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,23 +3,24 @@ module git.veillax.com/Veillax/hammerhead
|
|||||||
go 1.27.3
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.veillax.com/Veillax/bamboo v0.1.5
|
git.veillax.com/Veillax/bamboo v0.1.9
|
||||||
git.veillax.com/Veillax/blacktip v0.1.9
|
git.veillax.com/Veillax/blacktip v0.1.10
|
||||||
git.veillax.com/Veillax/reef v0.1.11
|
git.veillax.com/Veillax/reef v0.1.12
|
||||||
github.com/emersion/go-msgauth v0.7.0
|
github.com/emersion/go-msgauth v0.7.0
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
|
golang.org/x/net v0.61.0
|
||||||
modernc.org/sqlite v1.61.0
|
modernc.org/sqlite v1.61.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.4 // indirect
|
github.com/Microsoft/go-winio v0.6.4 // indirect
|
||||||
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||||
github.com/google/btree v1.1.2 // indirect
|
github.com/google/btree v1.1.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/net v0.61.0 // indirect
|
|
||||||
golang.org/x/sys v0.49.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
golang.org/x/text v0.43.0 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
golang.org/x/time v0.7.0 // indirect
|
golang.org/x/time v0.7.0 // indirect
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
|
git.veillax.com/Veillax/bamboo v0.1.9 h1:D+DWIFRxy2MZ9Oa7fRrdeM7xNUBZPwjfO5CRMgc9+q4=
|
||||||
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
|
git.veillax.com/Veillax/bamboo v0.1.9/go.mod h1:Z3O7xoa52mFaADs7RxaBQl0a0K+5HlHPB1eOYQjM15k=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo=
|
git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY=
|
git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o=
|
||||||
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
|
git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s=
|
||||||
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
git.veillax.com/Veillax/reef v0.1.12/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
||||||
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||||
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||||
@@ -34,6 +36,8 @@ golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
|||||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
s.registerAlertRoutes(mux)
|
s.registerAlertRoutes(mux)
|
||||||
mux.HandleFunc("GET /metrics", s.metrics)
|
mux.HandleFunc("GET /metrics", s.metrics)
|
||||||
|
mux.Handle("GET /static/term/", terminalStatic())
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/terminal", s.withOrg(true, s.terminalKey))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/terminal", s.withOrg(true, s.siteTerminal))
|
||||||
|
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}/terminal/hostkey", s.withOrg(true, s.forgetHostKey))
|
||||||
|
|
||||||
// public
|
// public
|
||||||
mux.HandleFunc("GET /api/public", s.publicInfo)
|
mux.HandleFunc("GET /api/public", s.publicInfo)
|
||||||
@@ -171,5 +175,6 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /edge/logs", s.edgeLogs)
|
mux.HandleFunc("POST /edge/logs", s.edgeLogs)
|
||||||
mux.HandleFunc("GET /site/config", s.siteConfig)
|
mux.HandleFunc("GET /site/config", s.siteConfig)
|
||||||
mux.HandleFunc("POST /site/report", s.siteReport)
|
mux.HandleFunc("POST /site/report", s.siteReport)
|
||||||
|
mux.HandleFunc("GET /site/terminal/{id}", s.terminalCallBack)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
@@ -208,6 +210,7 @@ type Server struct {
|
|||||||
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
|
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
|
||||||
containers map[string][]model.Container // by site ID
|
containers map[string][]model.Container // by site ID
|
||||||
discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found
|
discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found
|
||||||
|
terms map[string]*pendingTerm // terminal sessions waiting for an agent to call back, by session ID
|
||||||
changed chan struct{}
|
changed chan struct{}
|
||||||
|
|
||||||
tickets map[string]ticket
|
tickets map[string]ticket
|
||||||
@@ -261,6 +264,7 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
mailChecks: map[string]model.MailCheck{},
|
mailChecks: map[string]model.MailCheck{},
|
||||||
containers: map[string][]model.Container{},
|
containers: map[string][]model.Container{},
|
||||||
discovered: map[string][]model.DiscoveredService{},
|
discovered: map[string][]model.DiscoveredService{},
|
||||||
|
terms: map[string]*pendingTerm{},
|
||||||
changed: make(chan struct{}),
|
changed: make(chan struct{}),
|
||||||
tickets: map[string]ticket{},
|
tickets: map[string]ticket{},
|
||||||
oauth: map[string]oauthState{},
|
oauth: map[string]oauthState{},
|
||||||
@@ -410,9 +414,15 @@ func (s *Server) bump() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wait blocks until the version moves past have, or 25s pass. It returns false on timeout.
|
// wait blocks until the version moves past have, or 25s pass. It returns false on timeout.
|
||||||
func (s *Server) wait(r *http.Request, have int64) bool {
|
func (s *Server) wait(r *http.Request, have int64) bool { return s.waitUntil(r, have, nil) }
|
||||||
|
|
||||||
|
// waitUntil is wait that also returns at once when ready (called with s.mu held) says there is
|
||||||
|
// something to deliver that does not move the version, such as a terminal waiting for an agent.
|
||||||
|
// Checking it under the same lock that takes the change channel is what keeps a request that
|
||||||
|
// arrives between two polls from being missed.
|
||||||
|
func (s *Server) waitUntil(r *http.Request, have int64, ready func() bool) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.st.Version > have {
|
if s.st.Version > have || (ready != nil && ready()) {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
+568
@@ -0,0 +1,568 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The web terminal opens a shell on a site from the dashboard. Hammerhead is the SSH client: it
|
||||||
|
// signs in to the SSH port that an edge exposes for the site (a TCP resource whose target is
|
||||||
|
// Bamboo's built-in SSH server) with a key of its own, and relays the shell to the browser over a
|
||||||
|
// WebSocket. Bamboo only lets in keys listed in its authorized_keys, so a site is reachable this
|
||||||
|
// way only after someone puts Hammerhead's public key there.
|
||||||
|
|
||||||
|
//go:embed web/term
|
||||||
|
var termFiles embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
sshTargetPort = 2222 // where Bamboo's SSH server listens by default
|
||||||
|
termIdle = 30 * time.Minute
|
||||||
|
termMaxSessions = 8
|
||||||
|
termMaxFrame = 64 << 10
|
||||||
|
termDialTimeout = 10 * time.Second
|
||||||
|
termDefaultTerm = "xterm-256color"
|
||||||
|
termResizeMaxCol = 500
|
||||||
|
termResizeMaxRow = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// TerminalSettings is what the web terminal remembers: its own SSH key, and the host key each
|
||||||
|
// site showed the first time (trusted on first use, like an ordinary ssh client).
|
||||||
|
type TerminalSettings struct {
|
||||||
|
Key string `json:"key,omitempty"` // PKCS#8 PEM private key; sealed at rest, never sent to the browser
|
||||||
|
Hosts map[string]string `json:"hosts,omitempty"` // site ID -> host key fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalStatic serves the embedded xterm.js files.
|
||||||
|
func terminalStatic() http.Handler {
|
||||||
|
sub, _ := fs.Sub(termFiles, "web/term")
|
||||||
|
fileServer := http.StripPrefix("/static/term/", http.FileServer(http.FS(sub)))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalSigner returns Hammerhead's SSH key, making it on first use. Caller holds s.mu.
|
||||||
|
func (s *Server) terminalSignerLocked() (ssh.Signer, error) {
|
||||||
|
if s.st.Settings.Terminal.Key == "" {
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.st.Settings.Terminal.Key = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
|
||||||
|
s.save()
|
||||||
|
}
|
||||||
|
return ssh.ParsePrivateKey([]byte(s.st.Settings.Terminal.Key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalKey tells an administrator which public key to put in a site's authorized_keys.
|
||||||
|
func (s *Server) terminalKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
signer, err := s.terminalSignerLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) + " hammerhead"
|
||||||
|
writeJSON(w, 200, map[string]any{"publicKey": line, "fingerprint": ssh.FingerprintSHA256(signer.PublicKey())})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSSHResource reports whether m is a TCP port that leads to the SSH server of the given site.
|
||||||
|
func isSSHResource(m *model.Mapping, siteID string) bool {
|
||||||
|
if m.Proto != model.ProtoTCP || m.IsRedirect() || !m.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, t := range m.Targets {
|
||||||
|
if t.SiteID != siteID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Port == sshTargetPort {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, tag := range m.Tags {
|
||||||
|
if tag == "ssh" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalAddr finds where to connect for a site: the public address of the SSH port that
|
||||||
|
// leads to it. A port bound to every address is reached through the edge's own address.
|
||||||
|
func (s *Server) terminalAddr(c *orgCtx, siteID string) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.findSite(siteID)
|
||||||
|
if st == nil || st.OrgID != c.Org.ID || st.Local {
|
||||||
|
return "", errors.New("no such site")
|
||||||
|
}
|
||||||
|
for _, m := range s.st.Mappings {
|
||||||
|
if m.OrgID != c.Org.ID || !isSSHResource(m, siteID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host := m.BindIP
|
||||||
|
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
||||||
|
e := s.findEdge(m.EdgeID)
|
||||||
|
if e == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h, _, err := net.SplitHostPort(e.TunnelAddr)
|
||||||
|
if err != nil || h == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, strconv.Itoa(m.Port)), nil
|
||||||
|
}
|
||||||
|
return "", errors.New("This site has no SSH port yet. Open one from the SSH button on the Sites page.")
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostKeyChanged struct{ site, was, now string }
|
||||||
|
|
||||||
|
func (e *hostKeyChanged) Error() string {
|
||||||
|
return fmt.Sprintf("The host key of %s has changed (was %s, now %s). If you reinstalled Bamboo on it, forget the saved key and try again.", e.site, e.was, e.now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustHostKey remembers a site's host key the first time and refuses a different one afterwards.
|
||||||
|
func (s *Server) trustHostKey(siteID, siteName string) ssh.HostKeyCallback {
|
||||||
|
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||||
|
fp := ssh.FingerprintSHA256(key)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
t := &s.st.Settings.Terminal
|
||||||
|
switch known := t.Hosts[siteID]; {
|
||||||
|
case known == "":
|
||||||
|
if t.Hosts == nil {
|
||||||
|
t.Hosts = map[string]string{}
|
||||||
|
}
|
||||||
|
t.Hosts[siteID] = fp
|
||||||
|
s.save()
|
||||||
|
return nil
|
||||||
|
case known != fp:
|
||||||
|
return &hostKeyChanged{siteName, known, fp}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) forgetHostKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if st := s.findSite(id); st == nil || st.OrgID != c.Org.ID {
|
||||||
|
fail(w, 404, errors.New("no such site"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(s.st.Settings.Terminal.Hosts, id)
|
||||||
|
s.save()
|
||||||
|
s.audit(c.Org.ID, c.Actor, "terminal.forget-host-key", id)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameOrigin lets a browser open the terminal only from this dashboard: a page on another site
|
||||||
|
// cannot use a signed-in administrator's cookie to start a shell. Requests without an Origin
|
||||||
|
// header do not come from a browser page.
|
||||||
|
func sameOrigin(r *http.Request) bool {
|
||||||
|
o := r.Header.Get("Origin")
|
||||||
|
if o == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, err := url.Parse(o)
|
||||||
|
return err == nil && strings.EqualFold(u.Host, r.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
type termFrame struct {
|
||||||
|
text bool
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// frames reads and writes WebSocket frames keeping their type: binary frames carry the terminal's
|
||||||
|
// bytes, text frames carry control messages (resize, exit).
|
||||||
|
var frames = websocket.Codec{
|
||||||
|
Marshal: func(v any) ([]byte, byte, error) {
|
||||||
|
f := v.(termFrame)
|
||||||
|
if f.text {
|
||||||
|
return f.data, websocket.TextFrame, nil
|
||||||
|
}
|
||||||
|
return f.data, websocket.BinaryFrame, nil
|
||||||
|
},
|
||||||
|
Unmarshal: func(data []byte, pt byte, v any) error {
|
||||||
|
*(v.(*termFrame)) = termFrame{text: pt == websocket.TextFrame, data: data}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type termControl struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cols int `json:"cols,omitempty"`
|
||||||
|
Rows int `json:"rows,omitempty"`
|
||||||
|
Code int `json:"code,omitempty"`
|
||||||
|
|
||||||
|
Message string `json:"message,omitempty"` // type "error": what went wrong, in words for the person at the keyboard
|
||||||
|
HostKeyChanged bool `json:"hostKeyChanged,omitempty"` // type "error": the site's host key is not the one saved, so offer to forget it
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
var termSlots = make(chan struct{}, termMaxSessions)
|
||||||
|
|
||||||
|
// siteTerminal is the WebSocket that carries one shell session. The connection is accepted first
|
||||||
|
// and any problem (no SSH port, a refused key, a changed host key) is sent as an "error" message,
|
||||||
|
// because a browser cannot read the body of a failed WebSocket handshake.
|
||||||
|
func (s *Server) siteTerminal(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
if !sameOrigin(r) {
|
||||||
|
fail(w, 403, errors.New("The terminal can only be opened from the dashboard itself."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
siteID := r.PathValue("id")
|
||||||
|
cols, _ := strconv.Atoi(r.URL.Query().Get("cols"))
|
||||||
|
rows, _ := strconv.Atoi(r.URL.Query().Get("rows"))
|
||||||
|
cols, rows = clamp(cols, 20, termResizeMaxCol), clamp(rows, 5, termResizeMaxRow)
|
||||||
|
|
||||||
|
srv := websocket.Server{
|
||||||
|
Handshake: func(*websocket.Config, *http.Request) error { return nil }, // the origin was checked above
|
||||||
|
Handler: func(ws *websocket.Conn) {
|
||||||
|
ws.MaxPayloadBytes = termMaxFrame
|
||||||
|
s.runTerminal(ws, c, siteID, cols, rows)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols, rows int) {
|
||||||
|
var wmu sync.Mutex
|
||||||
|
send := func(f termFrame) error {
|
||||||
|
wmu.Lock()
|
||||||
|
defer wmu.Unlock()
|
||||||
|
return frames.Send(ws, f)
|
||||||
|
}
|
||||||
|
control := func(v termControl) {
|
||||||
|
msg, _ := json.Marshal(v)
|
||||||
|
send(termFrame{text: true, data: msg})
|
||||||
|
}
|
||||||
|
problem := func(msg string) {
|
||||||
|
control(termControl{Type: "error", Message: msg})
|
||||||
|
time.Sleep(200 * time.Millisecond) // let it reach the browser before the connection closes
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
signer, err := s.terminalSignerLocked()
|
||||||
|
siteName, viaAgent, agentCanRelay := "", false, false
|
||||||
|
if st := s.findSite(siteID); st != nil {
|
||||||
|
siteName, viaAgent, agentCanRelay = st.Name, st.SSH != "" && st.SSHRelay, st.SSHRelay
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
problem(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case termSlots <- struct{}{}:
|
||||||
|
defer func() { <-termSlots }()
|
||||||
|
default:
|
||||||
|
problem("Too many terminals are open. Close one and try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||||
|
HostKeyCallback: s.trustHostKey(siteID, siteName), Timeout: termDialTimeout}
|
||||||
|
var client *ssh.Client
|
||||||
|
if viaAgent {
|
||||||
|
// Bamboo reports its SSH server, so the agent calls back and no port has to be open anywhere.
|
||||||
|
var release func()
|
||||||
|
client, release, err = s.dialViaAgent(siteID, cfg)
|
||||||
|
defer func() {
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
addr, aerr := s.terminalAddr(c, siteID)
|
||||||
|
if aerr != nil {
|
||||||
|
switch {
|
||||||
|
case agentCanRelay: // a current Bamboo whose SSH server is not running
|
||||||
|
problem("That machine's SSH server isn't running. Start Bamboo there with -ssh, and add Hammerhead's public key to authorized_keys.")
|
||||||
|
default:
|
||||||
|
problem(aerr.Error() + " Updating Bamboo on that machine lets the terminal work without any port.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, err = ssh.Dial("tcp", addr, cfg)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
var hk *hostKeyChanged
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &hk):
|
||||||
|
control(termControl{Type: "error", Message: hk.Error(), HostKeyChanged: true})
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
case errors.Is(err, errAgentSilent):
|
||||||
|
problem("The site didn't answer. Is Bamboo running there and connected to Hammerhead?")
|
||||||
|
case strings.Contains(err.Error(), "unable to authenticate"):
|
||||||
|
problem("The site refused Hammerhead's key. Add Hammerhead's public key to authorized_keys on that machine.")
|
||||||
|
default:
|
||||||
|
problem(fmt.Sprintf("Couldn't reach the site's SSH server: is Bamboo running with -ssh? (%v)", err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
sess, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
problem(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sess.Close()
|
||||||
|
if err := sess.RequestPty(termDefaultTerm, rows, cols, ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400}); err != nil {
|
||||||
|
problem(fmt.Sprintf("The site wouldn't give a terminal: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdin, err := sess.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
problem(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdout, err := sess.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
problem(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sess.Shell(); err != nil {
|
||||||
|
problem(fmt.Sprintf("Couldn't start a shell: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.audit(c.Org.ID, c.Actor, "terminal.open", siteName)
|
||||||
|
started := time.Now()
|
||||||
|
defer func() {
|
||||||
|
s.audit(c.Org.ID, c.Actor, "terminal.close", fmt.Sprintf("%s after %s", siteName, time.Since(started).Round(time.Second)))
|
||||||
|
}()
|
||||||
|
control(termControl{Type: "ready"})
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
stop := func() { once.Do(func() { close(done); ws.Close() }) }
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
waited := make(chan struct{}) // closed once the browser has been told how the shell ended
|
||||||
|
go func() { // shell output to the browser
|
||||||
|
defer func() {
|
||||||
|
select { // the output ends just before the shell is reaped: let the exit message go out first
|
||||||
|
case <-waited:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}()
|
||||||
|
buf := make([]byte, 16<<10)
|
||||||
|
for {
|
||||||
|
n, err := stdout.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if send(termFrame{data: append([]byte(nil), buf[:n]...)}) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() { // the shell ended: say how, then close
|
||||||
|
code := 0
|
||||||
|
if err := sess.Wait(); err != nil {
|
||||||
|
var ee *ssh.ExitError
|
||||||
|
if errors.As(err, &ee) {
|
||||||
|
code = ee.ExitStatus()
|
||||||
|
} else if !errors.Is(err, io.EOF) {
|
||||||
|
code = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
control(termControl{Type: "exit", Code: code})
|
||||||
|
close(waited)
|
||||||
|
time.Sleep(200 * time.Millisecond) // let the exit message reach the browser first
|
||||||
|
stop()
|
||||||
|
}()
|
||||||
|
idle := time.AfterFunc(termIdle, func() {
|
||||||
|
control(termControl{Type: "idle"})
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
defer idle.Stop()
|
||||||
|
for { // browser to shell
|
||||||
|
var f termFrame
|
||||||
|
if err := frames.Receive(ws, &f); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idle.Reset(termIdle)
|
||||||
|
if !f.text {
|
||||||
|
if _, err := stdin.Write(f.data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ctl termControl
|
||||||
|
if json.Unmarshal(f.data, &ctl) == nil && ctl.Type == "resize" {
|
||||||
|
sess.WindowChange(clamp(ctl.Rows, 5, termResizeMaxRow), clamp(ctl.Cols, 20, termResizeMaxCol))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- the call-back: a terminal without any open port ----------
|
||||||
|
|
||||||
|
// termCallBack is how long Hammerhead waits for an agent to answer a terminal request. The agent
|
||||||
|
// notices the request within a moment, because it is long-polling for its configuration.
|
||||||
|
const termCallBack = 15 * time.Second
|
||||||
|
|
||||||
|
var errAgentSilent = errors.New("the agent did not call back")
|
||||||
|
|
||||||
|
// pendingTerm is a terminal session waiting for the site's agent to call back.
|
||||||
|
type pendingTerm struct {
|
||||||
|
site string
|
||||||
|
created time.Time
|
||||||
|
claimed bool
|
||||||
|
offered bool // an agent has been sent this session in its configuration
|
||||||
|
conn chan net.Conn // the agent's call-back connection, once it arrives
|
||||||
|
done chan struct{} // closed when Hammerhead has finished with that connection
|
||||||
|
}
|
||||||
|
|
||||||
|
// wakeAgentsLocked ends every long poll now, so an agent hears about a change at once.
|
||||||
|
// The configuration version does not move, so nothing is saved or re-applied.
|
||||||
|
func (s *Server) wakeAgentsLocked() {
|
||||||
|
close(s.changed)
|
||||||
|
s.changed = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitingTerminalsLocked lists the sessions an agent should answer, and forgets stale ones.
|
||||||
|
func (s *Server) waitingTerminalsLocked(siteID string) []string {
|
||||||
|
var ids []string
|
||||||
|
for id, p := range s.terms {
|
||||||
|
if time.Since(p.created) > 2*termCallBack {
|
||||||
|
delete(s.terms, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.site == siteID && !p.claimed {
|
||||||
|
ids = append(ids, id)
|
||||||
|
p.offered = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTerminalsLocked reports whether the site has a session its agent has not been told about yet,
|
||||||
|
// which makes that agent's long poll return straight away instead of waiting for a change.
|
||||||
|
func (s *Server) newTerminalsLocked(siteID string) bool {
|
||||||
|
for _, p := range s.terms {
|
||||||
|
if p.site == siteID && !p.claimed && !p.offered {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialViaAgent asks the site's agent to call back and signs in to its SSH server over that
|
||||||
|
// connection. release must be called when the session is over.
|
||||||
|
func (s *Server) dialViaAgent(siteID string, cfg *ssh.ClientConfig) (*ssh.Client, func(), error) {
|
||||||
|
id := randHex(12)
|
||||||
|
p := &pendingTerm{site: siteID, created: time.Now(), conn: make(chan net.Conn, 1), done: make(chan struct{})}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.terms[id] = p
|
||||||
|
s.wakeAgentsLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
var once sync.Once
|
||||||
|
release := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.terms, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
close(p.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var conn net.Conn
|
||||||
|
select {
|
||||||
|
case conn = <-p.conn:
|
||||||
|
case <-time.After(termCallBack):
|
||||||
|
release()
|
||||||
|
return nil, nil, errAgentSilent
|
||||||
|
}
|
||||||
|
conn.SetDeadline(time.Now().Add(termDialTimeout))
|
||||||
|
cc, chans, reqs, err := ssh.NewClientConn(conn, "site", cfg)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
release()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
conn.SetDeadline(time.Time{})
|
||||||
|
return ssh.NewClient(cc, chans, reqs), release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalCallBack is where an agent connects to answer a terminal request. The connection is
|
||||||
|
// handed to the session that asked for it and carries that session's SSH traffic.
|
||||||
|
func (s *Server) terminalCallBack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
st := s.siteByToken(r)
|
||||||
|
var p *pendingTerm
|
||||||
|
if st != nil {
|
||||||
|
if q := s.terms[r.PathValue("id")]; q != nil && q.site == st.ID && !q.claimed {
|
||||||
|
q.claimed, p = true, q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case st == nil:
|
||||||
|
fail(w, 401, errors.New("bad site token"))
|
||||||
|
return
|
||||||
|
case p == nil:
|
||||||
|
fail(w, 404, errors.New("no such terminal"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websocket.Server{
|
||||||
|
Handshake: func(*websocket.Config, *http.Request) error { return nil }, // authenticated by the site token
|
||||||
|
Handler: func(ws *websocket.Conn) {
|
||||||
|
ws.PayloadType = websocket.BinaryFrame
|
||||||
|
ws.MaxPayloadBytes = termMaxFrame
|
||||||
|
select {
|
||||||
|
case p.conn <- ws:
|
||||||
|
<-p.done // the websocket closes when this handler returns
|
||||||
|
case <-time.After(termCallBack):
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clipText cuts s to at most n bytes.
|
||||||
|
func clipText(s string, n int) string {
|
||||||
|
if len(s) > n {
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
+152
-6
@@ -9,6 +9,7 @@
|
|||||||
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
||||||
:root {
|
:root {
|
||||||
--accent:#1f5c5c; --accent-contrast:#ffffff;
|
--accent:#1f5c5c; --accent-contrast:#ffffff;
|
||||||
|
--terminal-bg:#0c0c0c; /* a terminal stays dark in both themes */
|
||||||
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
|
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
|
||||||
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
|
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
|
||||||
--accent-text:var(--accent);
|
--accent-text:var(--accent);
|
||||||
@@ -102,6 +103,8 @@
|
|||||||
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
|
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
|
||||||
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
|
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
|
||||||
.scroll { overflow-x:auto; }
|
.scroll { overflow-x:auto; }
|
||||||
|
td.wrap { overflow-wrap:anywhere; min-width:150px; max-width:260px; }
|
||||||
|
.ports { display:flex; flex-wrap:wrap; gap:var(--s1); }
|
||||||
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
|
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
|
||||||
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
|
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
|
||||||
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
|
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
|
||||||
@@ -159,6 +162,8 @@
|
|||||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
||||||
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
||||||
|
.term { background:var(--terminal-bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2); height:min(60vh,460px); overflow:hidden; }
|
||||||
|
.term .xterm-viewport { background-color:var(--terminal-bg) !important; }
|
||||||
.tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); }
|
.tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); }
|
||||||
.tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); }
|
.tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); }
|
||||||
.chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; }
|
.chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; }
|
||||||
@@ -179,7 +184,7 @@
|
|||||||
@keyframes shimmer { to { background-position:-200% 0; } }
|
@keyframes shimmer { to { background-position:-200% 0; } }
|
||||||
|
|
||||||
/* dialogs, tabs, toasts */
|
/* dialogs, tabs, toasts */
|
||||||
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
|
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; grid-template-columns:minmax(0,1fr); place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
|
||||||
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
|
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
|
||||||
.modal.wide { width:min(780px,100%); }
|
.modal.wide { width:min(780px,100%); }
|
||||||
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
|
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
|
||||||
@@ -372,6 +377,12 @@ function modal(title, body, opts = {}) {
|
|||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
const top = [...document.querySelectorAll('.shade')].pop();
|
const top = [...document.querySelectorAll('.shade')].pop();
|
||||||
if (top !== shade) return;
|
if (top !== shade) return;
|
||||||
|
// Inside a terminal every key belongs to the shell (Escape for vim, Tab for completion), except
|
||||||
|
// Ctrl+Alt+Escape, which moves focus out so the dialog can still be left from the keyboard.
|
||||||
|
if (opts.keys && e.target.closest && e.target.closest(opts.keys)) {
|
||||||
|
if (e.key === 'Escape' && e.ctrlKey && e.altKey) { e.preventDefault(); e.stopPropagation(); box.focus(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'Escape') { e.stopPropagation(); close(); }
|
if (e.key === 'Escape') { e.stopPropagation(); close(); }
|
||||||
else if (e.key === 'Tab') {
|
else if (e.key === 'Tab') {
|
||||||
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
|
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
|
||||||
@@ -1028,6 +1039,143 @@ async function shareDialog(m) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----- sites -----
|
// ----- sites -----
|
||||||
|
// What an agent reports about its network: Docker containers (-docker) and listening services (-discover).
|
||||||
|
async function siteFound(s) {
|
||||||
|
const [list, found] = await Promise.all([api('GET', O(`/sites/${s.id}/containers`)), api('GET', O(`/sites/${s.id}/discovered`))]);
|
||||||
|
// a published port is "8777→8777"; TCP and UDP on the same port share one entry
|
||||||
|
const portChips = ports => {
|
||||||
|
const by = new Map();
|
||||||
|
for (const p of ports) { const k = (p.public ? p.public + '→' : '') + p.private; by.set(k, [...(by.get(k) || []), p.type]); }
|
||||||
|
return h('div', {class:'ports'}, [...by].map(([k, types]) => h('span', {class:'tag mono'}, k + '/' + [...new Set(types)].join(','))));
|
||||||
|
};
|
||||||
|
const section = (title, count, body, empty) => h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, title + (count ? ` (${count})` : '')), count ? h('div', {class:'scroll'}, body) : h('p', {class:'dim', style:'margin:0'}, empty));
|
||||||
|
const containers = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))),
|
||||||
|
h('tbody', {}, list.map(c => h('tr', {}, h('td', {class:'wrap'}, c.name), h('td', {class:'dim wrap'}, c.image), h('td', {}, portChips(c.ports))))));
|
||||||
|
const services = h('table', {}, h('thead', {}, h('tr', {}, ['Service', 'Address', 'Found', 'Detail'].map(t => h('th', {}, t)))),
|
||||||
|
h('tbody', {}, found.map(d => h('tr', {}, h('td', {}, d.service || 'tcp'), h('td', {class:'mono small'}, d.host + ':' + d.port),
|
||||||
|
h('td', {class:'dim'}, d.source === 'local' ? 'Listening here' : 'Network scan'), h('td', {class:'dim small wrap'}, d.detail || '')))));
|
||||||
|
const close = modal('Found by ' + s.name, h('div', {class:'stack'},
|
||||||
|
h('p', {class:'dim', style:'margin:0'}, 'These show up as suggestions when you pick a target for a resource. Nothing is shared until you choose it.'),
|
||||||
|
section('Docker containers', list.length, containers, 'None reported. Start the agent with -docker (BAMBOO_DOCKER=1, or BLACKTIP_DOCKER=1 for an edge) on a machine where Docker is running.'),
|
||||||
|
section('Services on the network', found.length, services, 'None found. Start the agent with -discover (BAMBOO_DISCOVER=1) for services listening on its machine, and add -discover-subnets (BAMBOO_DISCOVER_SUBNETS) to scan other addresses.'),
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- terminal -----
|
||||||
|
// A shell on a site, in the dashboard. xterm.js is served by Hammerhead itself and loaded the first time it is needed.
|
||||||
|
let termLoad = null;
|
||||||
|
function loadTerm() {
|
||||||
|
if (!termLoad) {
|
||||||
|
const script = src => new Promise((ok, bad) => { const el = h('script', {src}); el.onload = ok; el.onerror = () => bad(new Error('Could not load the terminal.')); document.head.append(el); });
|
||||||
|
document.head.append(h('link', {rel:'stylesheet', href:'/static/term/xterm.css'}));
|
||||||
|
termLoad = script('/static/term/xterm.js').then(() => script('/static/term/addon-fit.js')).catch(e => { termLoad = null; throw e; });
|
||||||
|
}
|
||||||
|
return termLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTerminal(s) {
|
||||||
|
await loadTerm();
|
||||||
|
const box = h('div', {class:'term', role:'group', 'aria-label':'Terminal on ' + s.name});
|
||||||
|
const status = h('div', {class:'small dim', role:'status'}, 'Connecting…');
|
||||||
|
const forget = h('button', {class:'ghost', type:'button', style:'display:none', onclick: act(async () => { await api('DELETE', O(`/sites/${s.id}/terminal/hostkey`)); toast('Saved host key forgotten. Open the terminal again.'); })}, 'Forget saved host key');
|
||||||
|
const term = new Terminal({cursorBlink:true, fontFamily:'ui-monospace, Menlo, Consolas, monospace', fontSize:14, scrollback:5000,
|
||||||
|
theme:{background:getComputedStyle(document.documentElement).getPropertyValue('--terminal-bg').trim()}});
|
||||||
|
const fit = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
let ws = null, ended = false, ro = null;
|
||||||
|
const close = modal('Terminal: ' + s.name, h('div', {class:'stack'}, box, status,
|
||||||
|
h('div', {class:'actions form-actions'}, forget, h('button', {class:'btn', onclick:() => close()}, 'Close'))),
|
||||||
|
{sticky:true, wide:true, keys:'.term', onClose:() => { ended = true; if (ro) ro.disconnect(); if (ws) ws.close(); term.dispose(); }});
|
||||||
|
term.open(box); fit.fit(); term.focus();
|
||||||
|
const say = (text, bad) => { status.textContent = text; status.className = 'small ' + (bad ? 'err' : 'dim'); };
|
||||||
|
ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${O(`/sites/${s.id}/terminal`)}?cols=${term.cols}&rows=${term.rows}`);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
ws.onmessage = ev => {
|
||||||
|
if (typeof ev.data !== 'string') { term.write(new Uint8Array(ev.data)); return; } // the shell's output
|
||||||
|
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (m.type === 'ready') say('Connected. Esc and Tab go to the shell. Press Ctrl+Alt+Esc to move focus out of the terminal.');
|
||||||
|
else if (m.type === 'error') { say(m.message, true); if (m.hostKeyChanged) forget.style.display = ''; }
|
||||||
|
else if (m.type === 'exit') say('The shell ended' + (m.code ? ` (exit code ${m.code})` : '') + '.');
|
||||||
|
else if (m.type === 'idle') say('Closed after 30 minutes without typing.', true);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (ended) return;
|
||||||
|
if (status.textContent === 'Connecting…') say('Could not open the terminal. Sign in again and retry.', true);
|
||||||
|
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
term.onData(d => { if (ws.readyState === 1) ws.send(enc.encode(d)); });
|
||||||
|
term.onBinary(d => { if (ws.readyState === 1) ws.send(Uint8Array.from(d, c => c.charCodeAt(0))); });
|
||||||
|
ro = new ResizeObserver(() => { fit.fit(); if (ws.readyState === 1) ws.send(JSON.stringify({type:'resize', cols:term.cols, rows:term.rows})); });
|
||||||
|
ro.observe(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bamboo's built-in SSH server (started with -ssh) listens here on the site by default.
|
||||||
|
const SSH_TARGET = '127.0.0.1:2222';
|
||||||
|
|
||||||
|
// Reach a site's Bamboo SSH server with an ordinary ssh client: a TCP port on an edge whose target is that server.
|
||||||
|
async function sshAccess(s) {
|
||||||
|
const st = S.st, key = await api('GET', O('/terminal'));
|
||||||
|
const [tHost, tPort] = [SSH_TARGET.split(':')[0], +SSH_TARGET.split(':')[1]];
|
||||||
|
const isSSH = m => m.proto === 'tcp' && !m.redirectTo && m.targets.some(t => t.siteId === s.id && (t.port === tPort || (m.tags || []).includes('ssh')));
|
||||||
|
let ports = st.mappings.filter(isSSH);
|
||||||
|
// what to type after the @: a domain pointed at the address when there is one, else the address itself
|
||||||
|
const edgeHost = (e, m) => {
|
||||||
|
if (m.bindIp && m.bindIp !== '0.0.0.0') { const d = st.domains.find(x => x.ip === m.bindIp); return d ? d.name : m.bindIp; }
|
||||||
|
const d = st.domains.find(x => x.ip && (!x.edgeId || x.edgeId === m.edgeId));
|
||||||
|
return d ? d.name : ((e && e.ips) || [])[0] || 'your-edge-address';
|
||||||
|
};
|
||||||
|
const cmds = h('div', {class:'stack'});
|
||||||
|
const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.'));
|
||||||
|
// Bamboo reports its SSH server; when it does, the terminal reaches it through the agent and needs no port
|
||||||
|
const viaAgent = !!(s.sshRelay && s.ssh);
|
||||||
|
const termBtn = h('button', {class:'btn', type:'button', onclick: act(async () => { close(); await openTerminal(s); })}, 'Open terminal');
|
||||||
|
const termNote = h('p', {class:'dim small', style:'margin:0'});
|
||||||
|
const draw = () => {
|
||||||
|
connectBox.style.display = ports.length ? '' : 'none';
|
||||||
|
termBtn.disabled = !viaAgent && !ports.length;
|
||||||
|
termNote.textContent = viaAgent ? 'No port needs to be open: Bamboo on the site connects out to Hammerhead for the session.'
|
||||||
|
: ports.length ? 'This uses the SSH port below. Updating Bamboo or turning on its SSH server makes a port unnecessary.'
|
||||||
|
: 'Needs Bamboo\'s SSH server to be running (see above), or an SSH port below.';
|
||||||
|
cmds.replaceChildren(...ports.map(m => {
|
||||||
|
const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), m)}`;
|
||||||
|
return h('div', {}, h('div', {class:'small dim'}, m.name + (m.enabled ? '' : ' (paused)')), cmdBox(cmd),
|
||||||
|
h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(cmd); toast('Copied'); })}, 'Copy'));
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name + (e.online ? '' : ' (offline)'))));
|
||||||
|
const ipSel = h('select', {});
|
||||||
|
const refreshIPs = () => { const e = byId(st.edges, edgeSel.value); ipSel.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...((e && e.ips) || []).map(ip => h('option', {value:ip}, ip))); };
|
||||||
|
edgeSel.onchange = refreshIPs; refreshIPs();
|
||||||
|
const port = h('input', {type:'number', min:1, max:65535, value:2222, placeholder:'2222'});
|
||||||
|
const target = h('input', {value:SSH_TARGET});
|
||||||
|
const create = act(async () => {
|
||||||
|
const [h2, p2] = target.value.trim().split(':');
|
||||||
|
if (!st.edges.length) throw new Error('Add an edge first');
|
||||||
|
const m = await api('POST', O('/mappings'), {name:'SSH to ' + s.name, proto:'tcp', edgeId:edgeSel.value, bindIp:ipSel.value || '0.0.0.0', port:+port.value, tags:['ssh'], enabled:true,
|
||||||
|
targets:[{siteId:s.id, host:h2 || tHost, port:+p2 || tPort}]});
|
||||||
|
ports = [...ports, m]; draw(); toast('SSH port opened'); refresh();
|
||||||
|
});
|
||||||
|
const close = modal('SSH into ' + s.name, h('div', {class:'stack'},
|
||||||
|
h('p', {class:'dim', style:'margin:0'}, 'Bamboo has a small SSH server, so you can open a shell on this machine without installing sshd. Start Bamboo with -ssh (BAMBOO_SSH=1) and put the public keys that may sign in in authorized_keys in its data folder. Only keys are accepted, never passwords.'),
|
||||||
|
h('div', {style:'display:flex;gap:var(--s2);align-items:center;flex-wrap:wrap'},
|
||||||
|
s.sshRelay ? (s.ssh ? badge('SSH server running', 'success') : badge('SSH server off', 'warning')) : badge('Bamboo needs an update', 'warning'),
|
||||||
|
h('span', {class:'small dim'}, s.sshRelay ? (s.ssh ? 'Listening on ' + s.ssh + ' on that machine.' : 'Start Bamboo there with -ssh.') : 'This Bamboo is too old to report its SSH server.')),
|
||||||
|
h('fieldset', {}, h('legend', {}, 'Terminal in Hammerhead'), h('div', {class:'stack'},
|
||||||
|
h('p', {class:'dim', style:'margin:0'}, 'Open a shell here in the dashboard. Hammerhead signs in with a key of its own, so add this line to authorized_keys on the site first (Bamboo picks it up without a restart).'),
|
||||||
|
cmdBox(key.publicKey),
|
||||||
|
termNote,
|
||||||
|
h('div', {class:'actions'}, h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(key.publicKey); toast('Copied'); })}, 'Copy key'), termBtn))),
|
||||||
|
connectBox,
|
||||||
|
h('fieldset', {}, h('legend', {}, 'Use an ordinary ssh client (optional)'), h('div', {class:'stack'},
|
||||||
|
h('p', {class:'dim small', style:'margin:0'}, 'Only needed to connect from your own computer. The terminal above does not use this port.'),
|
||||||
|
h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipSel), field('Public port', port, 'Anyone can reach it, but only listed keys sign in')),
|
||||||
|
field('Bamboo\'s SSH server on the site', target, 'Leave as is unless you changed -ssh-listen'),
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'btn', type:'button', onclick:create}, 'Open SSH port')))),
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
|
||||||
|
}
|
||||||
|
|
||||||
views.sites = async () => {
|
views.sites = async () => {
|
||||||
const st = S.st, name = h('input', {placeholder:'e.g. Home lab'});
|
const st = S.st, name = h('input', {placeholder:'e.g. Home lab'});
|
||||||
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
|
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
|
||||||
@@ -1047,17 +1195,15 @@ views.sites = async () => {
|
|||||||
};
|
};
|
||||||
return h('div', {},
|
return h('div', {},
|
||||||
tagBar('sites', tags),
|
tagBar('sites', tags),
|
||||||
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
|
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Discovered', ''].map(t => h('th', {}, t)))),
|
||||||
h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {},
|
h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {},
|
||||||
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)),
|
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)),
|
||||||
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
|
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
|
||||||
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
||||||
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
|
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
|
||||||
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
h('td', {}, h('button', {class:'ghost', onclick: act(() => siteFound(s))}, 'View')),
|
||||||
const l = await api('GET', O(`/sites/${s.id}/containers`));
|
|
||||||
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
|
|
||||||
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
|
|
||||||
h('td', {}, s.local ? null : h('div', {class:'actions'},
|
h('td', {}, s.local ? null : h('div', {class:'actions'},
|
||||||
|
h('button', {class:'ghost', onclick: act(() => sshAccess(s))}, 'SSH'),
|
||||||
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
|
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
|
||||||
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
||||||
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
|
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
|
||||||
|
|||||||
@@ -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