Files
Bamboo/terminal.go
T
VeillaxandClaude Sonnet 5.5 a2403ecf9d
CI / test (push) Successful in 3m51s
Release / release (push) Successful in 4m43s
Terminal relay: answer Hammerhead's terminal sessions through the agent
When someone opens a terminal for this site, Hammerhead lists the session in the
site's config. The agent connects out to Hammerhead and joins that connection to
its own SSH server, so no port has to be open on an edge. The agent now reports
where its SSH server listens and that it can relay.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 01:38:46 -06:00

100 lines
2.8 KiB
Go

package bamboo
import (
"io"
"log"
"net"
"strings"
"sync"
"time"
"golang.org/x/net/websocket"
)
// Terminal relay. When someone opens a terminal for this site in Hammerhead, Hammerhead lists a
// session in the site's config (model.SiteConfig.Terminals). For each one the agent opens a
// connection out to Hammerhead and joins it to its own SSH server, so Hammerhead can sign in over it.
// Nothing listens for this, no port has to be open on an edge, and the SSH server still decides who
// gets in: Hammerhead needs its public key in authorized_keys like any other client.
const (
termSeenFor = time.Minute // a session id is answered once, even if the config is fetched again
termDial = 10 * time.Second
)
// sshTarget is the address to dial for the local SSH server. An address that listens on every
// interface is reached on the loopback one.
func sshTarget(listen string) string {
if listen == "" {
return ""
}
host, port, err := net.SplitHostPort(listen)
if err != nil {
return listen
}
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// openTerminals starts a relay for each session Hammerhead is waiting on that has not been answered yet.
func (b *Bamboo) openTerminals(ids []string) {
for _, id := range ids {
b.mu.Lock()
if b.terms == nil {
b.terms = map[string]time.Time{}
}
for k, t := range b.terms {
if time.Since(t) > termSeenFor {
delete(b.terms, k)
}
}
_, dup := b.terms[id]
if !dup {
b.terms[id] = time.Now()
}
b.mu.Unlock()
if !dup {
go b.relayTerminal(id)
}
}
}
func (b *Bamboo) relayTerminal(id string) {
target := sshTarget(b.cfg.SSH)
if target == "" {
log.Printf("terminal: Hammerhead asked for a terminal but the SSH server is not running (start bamboo with -ssh)")
return
}
wsURL := "ws" + strings.TrimPrefix(b.cfg.Hammerhead, "http") + "/site/terminal/" + id
cfg, err := websocket.NewConfig(wsURL, b.cfg.Hammerhead)
if err != nil {
log.Printf("terminal: %v", err)
return
}
cfg.Header.Set("Authorization", "Bearer "+b.cfg.Token)
cfg.Dialer = &net.Dialer{Timeout: termDial}
ws, err := websocket.DialConfig(cfg)
if err != nil {
log.Printf("terminal: could not reach Hammerhead: %v", err)
return
}
ws.PayloadType = websocket.BinaryFrame
defer ws.Close()
local, err := net.DialTimeout("tcp", target, termDial)
if err != nil {
log.Printf("terminal: the SSH server at %s is not answering: %v", target, err)
return // closing the connection tells Hammerhead at once
}
defer local.Close()
var once sync.Once
stop := func() { once.Do(func() { ws.Close(); local.Close() }) }
done := make(chan struct{}, 2)
go func() { io.Copy(local, ws); stop(); done <- struct{}{} }()
go func() { io.Copy(ws, local); stop(); done <- struct{}{} }()
<-done
<-done
}