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>
This commit is contained in:
1 parent
1fba65fd3e
commit
a2403ecf9d
7 files changed
+225
-6
No files matched your search
+99
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user