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>
569 lines
17 KiB
Go
569 lines
17 KiB
Go
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
|
|
}
|