The shell's stderr was discarded, so a shell that could not start looked like an immediate disconnect. It now goes to the terminal like other output, followed by the exit code. An end-to-end test covers a machine with no shell. Builds against Bamboo v0.1.10, which says why when there is no shell to run. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
582 lines
17 KiB
Go
582 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
|
|
}
|
|
// What the shell writes to its error output (including why it could not start) goes to the
|
|
// terminal like everything else, instead of being thrown away.
|
|
sess.Stderr = termWriter(func(p []byte) error { return send(termFrame{data: append([]byte(nil), p...)}) })
|
|
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
|
|
}
|
|
|
|
// termWriter sends what is written to it to the browser as terminal output.
|
|
type termWriter func(p []byte) error
|
|
|
|
func (w termWriter) Write(p []byte) (int, error) {
|
|
if err := w(p); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(p), nil
|
|
}
|