1 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 35d4000bc4 Terminal without an open port: the agent calls back, and the SSH dialog shows its state
CI / test (push) Successful in 5m50s
Release / release (push) Successful in 5m42s
Bamboo reports where its SSH server listens. When a terminal is opened,
Hammerhead lists the session in the site's config and the agent connects out and
joins that connection to its SSH server; Hammerhead signs in over it. No port
resource or edge is involved. A site whose Bamboo does not report an SSH server
still works through an SSH port. The long poll now returns at once for a session
the agent has not been told about, so a request between two polls is not missed.

The SSH dialog shows whether the SSH server is running, says when no port is
needed, and offers the port only for an ordinary ssh client. Builds against Reef
v0.1.12 and Bamboo v0.1.9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 01:40:16 -06:00
10 changed files with 372 additions and 32 deletions

No files matched your search

+10 -7
View File
@@ -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). 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 - **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 keys in its `authorized_keys`. The **SSH** button on the Sites page shows Hammerhead's own public key: add
server and shows the `ssh` command to use. The same dialog shows Hammerhead's own public key: add it to it to `authorized_keys` and **Open terminal** gives an administrator a shell in the browser. No port has to
`authorized_keys` and **Open terminal** gives an administrator a shell in the browser. Hammerhead is the be open anywhere: Bamboo reports its SSH server, and when a terminal is opened it calls back to Hammerhead
SSH client (it signs in through that SSH port), saves each site's host key the first time and refuses a and joins that connection to the server. Hammerhead is the SSH client, saves each site's host key the
different one later (with a button to forget it), allows a terminal only from the dashboard's own origin, first time and refuses a different one later (with a button to forget it), allows a terminal only from the
logs opening and closing in the audit log, and closes an idle one after 30 minutes. The shell runs as the dashboard's own origin, logs opening and closing in the audit log, and closes an idle one after 30
user Bamboo runs as, so treat Hammerhead's key like a root login if Bamboo runs as root. minutes. The shell runs as the user Bamboo runs as, so treat Hammerhead's key like a root login if Bamboo
runs as root. To connect from your own computer with an ordinary `ssh` client, the same dialog can open a
TCP port on an edge to that server and shows the command to use. An older Bamboo that does not report its
SSH server can still be reached through such a port.
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP - **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
+3 -1
View File
@@ -195,7 +195,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64) have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
if !s.wait(r, have) { if !s.waitUntil(r, have, func() bool { return s.newTerminalsLocked(st.ID) }) {
s.mu.Lock() s.mu.Lock()
if st := s.siteByToken(r); st != nil { if st := s.siteByToken(r); st != nil {
st.LastSeen = time.Now() st.LastSeen = time.Now()
@@ -235,6 +235,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password}) cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
} }
} }
cfg.Terminals = s.waitingTerminalsLocked(st.ID)
writeJSON(w, 200, cfg) writeJSON(w, 200, cfg)
} }
@@ -260,6 +261,7 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version) s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
s.containers[st.ID] = in.Containers s.containers[st.ID] = in.Containers
s.discovered[st.ID] = cleanDiscovered(in.Discovered) s.discovered[st.ID] = cleanDiscovered(in.Discovered)
st.SSH, st.SSHRelay = clipText(in.SSH, 100), in.SSHRelay
for _, x := range s.st.MailEgress { for _, x := range s.st.MailEgress {
if x.SiteID != st.ID { if x.SiteID != st.ID {
continue continue
+166
View File
@@ -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
}
+2 -2
View File
@@ -85,8 +85,8 @@ func TestWebTerminalThroughTheEdgeToBamboosServer(t *testing.T) {
anon := newClient(t, w.ts.URL) anon := newClient(t, w.ts.URL)
anon.expect(401, "GET", base+"/terminal", nil) anon.expect(401, "GET", base+"/terminal", nil)
// asking before any SSH port exists says what to do // 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 port") { if m := first(); m.Type != "error" || !strings.Contains(m.Message, "-ssh") {
t.Errorf("before opening an SSH port: %+v", m) t.Errorf("before opening an SSH port: %+v", m)
} }
+2 -2
View File
@@ -3,9 +3,9 @@ module git.veillax.com/Veillax/hammerhead
go 1.27.3 go 1.27.3
require ( 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/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 golang.org/x/net v0.61.0
+4 -4
View File
@@ -1,9 +1,9 @@
git.veillax.com/Veillax/bamboo v0.1.8 h1:mOdkGBzM8CjFVjR3784/WAYGt3U4bSdOaAJqFWMVh3I= git.veillax.com/Veillax/bamboo v0.1.9 h1:D+DWIFRxy2MZ9Oa7fRrdeM7xNUBZPwjfO5CRMgc9+q4=
git.veillax.com/Veillax/bamboo v0.1.8/go.mod h1:hUMWRFuDIiTNaqjpx2F7RxOg7ussrbVZAQIhzhPF780= 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 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE=
git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o= git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o=
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY= git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s=
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= git.veillax.com/Veillax/reef v0.1.12/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+1
View File
@@ -175,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
} }
+10 -2
View File
@@ -210,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
@@ -263,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{},
@@ -412,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
} }
+159 -9
View File
@@ -280,16 +280,11 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols,
} }
defer ws.Close() defer ws.Close()
addr, err := s.terminalAddr(c, siteID)
if err != nil {
problem(err.Error())
return
}
s.mu.Lock() s.mu.Lock()
signer, err := s.terminalSignerLocked() signer, err := s.terminalSignerLocked()
siteName := "" siteName, viaAgent, agentCanRelay := "", false, false
if st := s.findSite(siteID); st != nil { if st := s.findSite(siteID); st != nil {
siteName = st.Name siteName, viaAgent, agentCanRelay = st.Name, st.SSH != "" && st.SSHRelay, st.SSHRelay
} }
s.mu.Unlock() s.mu.Unlock()
if err != nil { if err != nil {
@@ -304,14 +299,39 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols,
return return
} }
client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, cfg := &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: s.trustHostKey(siteID, siteName), Timeout: termDialTimeout}) 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 { if err != nil {
var hk *hostKeyChanged var hk *hostKeyChanged
switch { switch {
case errors.As(err, &hk): case errors.As(err, &hk):
control(termControl{Type: "error", Message: hk.Error(), HostKeyChanged: true}) control(termControl{Type: "error", Message: hk.Error(), HostKeyChanged: true})
time.Sleep(200 * time.Millisecond) 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"): 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.") problem("The site refused Hammerhead's key. Add Hammerhead's public key to authorized_keys on that machine.")
default: 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
}
+15 -5
View File
@@ -1127,11 +1127,16 @@ async function sshAccess(s) {
}; };
const cmds = h('div', {class:'stack'}); const cmds = h('div', {class:'stack'});
const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.')); const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.'));
// 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 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 = () => { const draw = () => {
connectBox.style.display = ports.length ? '' : 'none'; connectBox.style.display = ports.length ? '' : 'none';
termBtn.disabled = !ports.length; termBtn.disabled = !viaAgent && !ports.length;
termBtn.title = ports.length ? '' : 'Open an SSH port first'; 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 => { cmds.replaceChildren(...ports.map(m => {
const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), m)}`; const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), m)}`;
return h('div', {}, h('div', {class:'small dim'}, m.name + (m.enabled ? '' : ' (paused)')), cmdBox(cmd), return h('div', {}, h('div', {class:'small dim'}, m.name + (m.enabled ? '' : ' (paused)')), cmdBox(cmd),
@@ -1153,13 +1158,18 @@ async function sshAccess(s) {
ports = [...ports, m]; draw(); toast('SSH port opened'); refresh(); ports = [...ports, m]; draw(); toast('SSH port opened'); refresh();
}); });
const close = modal('SSH into ' + s.name, h('div', {class:'stack'}, const close = modal('SSH into ' + s.name, h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Bamboo has a small SSH server, so you can open a shell on this machine from anywhere without installing sshd. Start Bamboo with -ssh (BAMBOO_SSH=1) and put the public keys that may sign in in authorized_keys in its data folder. Only keys are accepted, never passwords.'), h('p', {class:'dim', style:'margin:0'}, 'Bamboo has a small SSH server, so you can open a shell on this machine 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('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('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).'), 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), 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('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')), h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipSel), field('Public port', port, 'Anyone can reach it, but only listed keys sign in')),
field('Bamboo\'s SSH server on the site', target, 'Leave as is unless you changed -ssh-listen'), field('Bamboo\'s SSH server on the site', target, 'Leave as is unless you changed -ssh-listen'),
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', type:'button', onclick:create}, 'Open SSH port')))),