From 35d4000bc48be6a72562d0dc444041bcfd5a90bb Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:40:16 -0600 Subject: [PATCH] Terminal without an open port: the agent calls back, and the SSH dialog shows its state Bamboo reports where its SSH server listens. When a terminal is opened, Hammerhead lists the session in the site's config and the agent connects out and joins that connection to its SSH server; Hammerhead signs in over it. No port resource or edge is involved. A site whose Bamboo does not report an SSH server still works through an SSH port. The long poll now returns at once for a session the agent has not been told about, so a request between two polls is not missed. The SSH dialog shows whether the SSH server is running, says when no port is needed, and offers the port only for an ordinary ssh client. Builds against Reef v0.1.12 and Bamboo v0.1.9. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 17 ++-- agent.go | 4 +- e2e/terminal_relay_test.go | 166 ++++++++++++++++++++++++++++++++++++ e2e/terminal_test.go | 4 +- go.mod | 4 +- go.sum | 8 +- server.go | 1 + store.go | 12 ++- terminal.go | 168 +++++++++++++++++++++++++++++++++++-- web/index.html | 20 +++-- 10 files changed, 372 insertions(+), 32 deletions(-) create mode 100644 e2e/terminal_relay_test.go diff --git a/README.md b/README.md index 74b5beb..00ac445 100644 --- a/README.md +++ b/README.md @@ -164,13 +164,16 @@ for each provider (e.g. `CF_DNS_API_TOKEN` for `cloudflare`); `.env` is the plac same offset (up to 1000 ports per entry; a busy port inside a range is named in the status). - TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI. - **SSH into a site, and a terminal in the dashboard.** Start Bamboo with `-ssh` and list the allowed public - keys in its `authorized_keys`. The **SSH** button on the Sites page opens a TCP port on an edge to that - server and shows the `ssh` command to use. The same dialog shows Hammerhead's own public key: add it to - `authorized_keys` and **Open terminal** gives an administrator a shell in the browser. Hammerhead is the - SSH client (it signs in through that SSH port), saves each site's host key the first time and refuses a - different one later (with a button to forget it), allows a terminal only from the dashboard's own origin, - logs opening and closing in the audit log, and closes an idle one after 30 minutes. The shell runs as the - user Bamboo runs as, so treat Hammerhead's key like a root login if Bamboo runs as root. + 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 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 diff --git a/agent.go b/agent.go index bfa150c..91d66eb 100644 --- a/agent.go +++ b/agent.go @@ -195,7 +195,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) { return } 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() if st := s.siteByToken(r); st != nil { 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.Terminals = s.waitingTerminalsLocked(st.ID) 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.containers[st.ID] = in.Containers s.discovered[st.ID] = cleanDiscovered(in.Discovered) + st.SSH, st.SSHRelay = clipText(in.SSH, 100), in.SSHRelay for _, x := range s.st.MailEgress { if x.SiteID != st.ID { continue diff --git a/e2e/terminal_relay_test.go b/e2e/terminal_relay_test.go new file mode 100644 index 0000000..2a11cf4 --- /dev/null +++ b/e2e/terminal_relay_test.go @@ -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 +} diff --git a/e2e/terminal_test.go b/e2e/terminal_test.go index fcfffd1..7eff1b5 100644 --- a/e2e/terminal_test.go +++ b/e2e/terminal_test.go @@ -85,8 +85,8 @@ func TestWebTerminalThroughTheEdgeToBamboosServer(t *testing.T) { anon := newClient(t, w.ts.URL) anon.expect(401, "GET", base+"/terminal", nil) - // asking before any SSH port exists says what to do - if m := first(); m.Type != "error" || !strings.Contains(m.Message, "SSH port") { + // 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) } diff --git a/go.mod b/go.mod index 6d29940..afc3bd9 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module git.veillax.com/Veillax/hammerhead go 1.27.3 require ( - git.veillax.com/Veillax/bamboo v0.1.8 + git.veillax.com/Veillax/bamboo 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 golang.org/x/crypto v0.58.0 golang.org/x/net v0.61.0 diff --git a/go.sum b/go.sum index c8f1676..e680409 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -git.veillax.com/Veillax/bamboo v0.1.8 h1:mOdkGBzM8CjFVjR3784/WAYGt3U4bSdOaAJqFWMVh3I= -git.veillax.com/Veillax/bamboo v0.1.8/go.mod h1:hUMWRFuDIiTNaqjpx2F7RxOg7ussrbVZAQIhzhPF780= +git.veillax.com/Veillax/bamboo v0.1.9 h1:D+DWIFRxy2MZ9Oa7fRrdeM7xNUBZPwjfO5CRMgc9+q4= +git.veillax.com/Veillax/bamboo v0.1.9/go.mod h1:Z3O7xoa52mFaADs7RxaBQl0a0K+5HlHPB1eOYQjM15k= git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE= git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o= -git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY= -git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= +git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s= +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/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= diff --git a/server.go b/server.go index a090863..bc297d1 100644 --- a/server.go +++ b/server.go @@ -175,5 +175,6 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /edge/logs", s.edgeLogs) mux.HandleFunc("GET /site/config", s.siteConfig) mux.HandleFunc("POST /site/report", s.siteReport) + mux.HandleFunc("GET /site/terminal/{id}", s.terminalCallBack) return mux } diff --git a/store.go b/store.go index 152305d..4a97772 100644 --- a/store.go +++ b/store.go @@ -210,6 +210,7 @@ type Server struct { mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report containers map[string][]model.Container // by site ID 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{} tickets map[string]ticket @@ -263,6 +264,7 @@ func New(dir string, opts Options) (*Server, error) { mailChecks: map[string]model.MailCheck{}, containers: map[string][]model.Container{}, discovered: map[string][]model.DiscoveredService{}, + terms: map[string]*pendingTerm{}, changed: make(chan struct{}), tickets: map[string]ticket{}, oauth: map[string]oauthState{}, @@ -412,9 +414,15 @@ func (s *Server) bump() { } // 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() - if s.st.Version > have { + if s.st.Version > have || (ready != nil && ready()) { s.mu.Unlock() return true } diff --git a/terminal.go b/terminal.go index 61939f1..ccbe989 100644 --- a/terminal.go +++ b/terminal.go @@ -280,16 +280,11 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols, } defer ws.Close() - addr, err := s.terminalAddr(c, siteID) - if err != nil { - problem(err.Error()) - return - } s.mu.Lock() signer, err := s.terminalSignerLocked() - siteName := "" + siteName, viaAgent, agentCanRelay := "", false, false if st := s.findSite(siteID); st != nil { - siteName = st.Name + siteName, viaAgent, agentCanRelay = st.Name, st.SSH != "" && st.SSHRelay, st.SSHRelay } s.mu.Unlock() if err != nil { @@ -304,14 +299,39 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols, return } - client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, - HostKeyCallback: s.trustHostKey(siteID, siteName), Timeout: termDialTimeout}) + 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: @@ -416,3 +436,133 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols, } } } + +// ---------- 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 +} diff --git a/web/index.html b/web/index.html index 9df7f23..17df760 100644 --- a/web/index.html +++ b/web/index.html @@ -1127,11 +1127,16 @@ async function sshAccess(s) { }; const cmds = h('div', {class:'stack'}); const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.')); + // 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 = !ports.length; - termBtn.title = ports.length ? '' : 'Open an SSH port first'; + 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), @@ -1153,13 +1158,18 @@ async function sshAccess(s) { 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 from anywhere without installing sshd. Start Bamboo with -ssh (BAMBOO_SSH=1) and put the public keys that may sign in in authorized_keys in its data folder. Only keys are accepted, never passwords.'), - connectBox, + h('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))), - h('fieldset', {}, h('legend', {}, 'Open an SSH port'), h('div', {class:'stack'}, + 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')))),