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>
529 lines
13 KiB
Go
529 lines
13 KiB
Go
// Package bamboo is the site agent. It only makes outbound connections: one
|
|
// HTTP long-poll to hammerhead for instructions, and one WireGuard tunnel
|
|
// (UDP) to each blacktip edge that forwards ports to this site. Inside the
|
|
// tunnel it accepts proxied connections and dials the real services.
|
|
package bamboo
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.veillax.com/Veillax/bamboo/discover"
|
|
"git.veillax.com/Veillax/reef/docker"
|
|
"git.veillax.com/Veillax/reef/model"
|
|
"git.veillax.com/Veillax/reef/tunnel"
|
|
"git.veillax.com/Veillax/reef/wg"
|
|
)
|
|
|
|
type Config struct {
|
|
Hammerhead string
|
|
Token string
|
|
DataDir string // holds this agent's WireGuard key
|
|
Docker bool // report local Docker containers so they can be picked as targets
|
|
Discover discover.Config // opt-in: report listening services found on this host and configured subnets as suggested targets
|
|
SSH string // where this machine's SSH server listens ("" = not running); Hammerhead's terminal reaches it through the agent
|
|
|
|
// Version, AutoUpdate and UpdateNow work as in blacktip.Config.
|
|
Version string
|
|
AutoUpdate bool
|
|
UpdateNow func(ctx context.Context, progress func(stage string, pct int, line string)) (newVersion string, err error)
|
|
}
|
|
|
|
type Bamboo struct {
|
|
cfg Config
|
|
http *http.Client
|
|
ctx context.Context
|
|
wgKey string
|
|
wgPub string
|
|
|
|
mu sync.Mutex
|
|
version int64
|
|
tunIP string
|
|
tun *wg.Device
|
|
edges []model.EdgeRef
|
|
lastRep string
|
|
terms map[string]time.Time // terminal sessions already answered, by id
|
|
|
|
updating atomic.Bool
|
|
updateMsg string // outcome of the update Hammerhead asked for, reported back
|
|
updateID string // the request that updateMsg answers
|
|
updStage string // progress of the running update, reported to Hammerhead
|
|
updPct int
|
|
updLog []string
|
|
updSent time.Time
|
|
|
|
disc *discover.Discoverer // nil unless discovery is switched on
|
|
|
|
mail map[string]*mailRun
|
|
mailErr map[string]string // mail egress ID -> "" (listening) or an error
|
|
}
|
|
|
|
func New(cfg Config) (*Bamboo, error) {
|
|
if cfg.DataDir == "" {
|
|
cfg.DataDir = "./bamboo-data"
|
|
}
|
|
key, err := loadOrCreateKey(filepath.Join(cfg.DataDir, "wg.key"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pub, err := wg.PublicKey(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b := &Bamboo{cfg: cfg, http: &http.Client{Timeout: 40 * time.Second}, ctx: context.Background(), wgKey: key, wgPub: pub,
|
|
mail: map[string]*mailRun{}, mailErr: map[string]string{}}
|
|
if cfg.Discover.Enabled() {
|
|
if b.disc, err = discover.New(cfg.Discover); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// loadOrCreateKey keeps this agent's WireGuard identity across restarts.
|
|
func loadOrCreateKey(path string) (string, error) {
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
if k := strings.TrimSpace(string(b)); k != "" {
|
|
return k, nil
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return "", err
|
|
}
|
|
k, err := wg.GenerateKey()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return k, os.WriteFile(path, []byte(k+"\n"), 0o600)
|
|
}
|
|
|
|
func (b *Bamboo) Run(ctx context.Context) error {
|
|
b.ctx = ctx
|
|
log.Printf("bamboo started; WireGuard public key %s", b.wgPub)
|
|
go b.reportLoop(ctx)
|
|
go b.refreshLoop(ctx)
|
|
if b.disc != nil {
|
|
go b.disc.Run(ctx, log.Printf)
|
|
}
|
|
for ctx.Err() == nil {
|
|
cfg, err := b.fetchConfig(ctx)
|
|
if err != nil {
|
|
log.Printf("config: %v", err)
|
|
select {
|
|
case <-time.After(3 * time.Second):
|
|
case <-ctx.Done():
|
|
}
|
|
continue
|
|
}
|
|
if cfg != nil {
|
|
b.apply(cfg)
|
|
}
|
|
}
|
|
b.mu.Lock()
|
|
for _, r := range b.mail {
|
|
r.ln.Close()
|
|
}
|
|
if b.tun != nil {
|
|
b.tun.Close()
|
|
}
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (b *Bamboo) fetchConfig(ctx context.Context) (*model.SiteConfig, error) {
|
|
b.mu.Lock()
|
|
v := b.version
|
|
b.mu.Unlock()
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/site/config?v=%d", b.cfg.Hammerhead, v), nil)
|
|
req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
|
|
resp, err := b.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
return nil, nil
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("hammerhead said %d: %s", resp.StatusCode, bytes.TrimSpace(body))
|
|
}
|
|
var cfg model.SiteConfig
|
|
return &cfg, json.NewDecoder(resp.Body).Decode(&cfg)
|
|
}
|
|
|
|
func (b *Bamboo) apply(cfg *model.SiteConfig) {
|
|
b.mu.Lock()
|
|
b.version = cfg.Version
|
|
b.edges = cfg.Edges
|
|
if cfg.UpdateID != "" {
|
|
b.requestUpdate(cfg.UpdateID)
|
|
}
|
|
if cfg.TunnelIP != b.tunIP && b.tun != nil {
|
|
b.tun.Close()
|
|
b.tun = nil
|
|
}
|
|
if b.tun == nil && cfg.TunnelIP != "" {
|
|
if err := b.startTunnel(cfg.TunnelIP); err != nil {
|
|
log.Printf("start WireGuard: %v", err)
|
|
}
|
|
}
|
|
b.mu.Unlock()
|
|
b.syncPeers()
|
|
b.syncMail(cfg.MailEgress)
|
|
b.openTerminals(cfg.Terminals)
|
|
go b.report()
|
|
}
|
|
|
|
// startTunnel creates the WireGuard device and starts serving inside it. Caller holds b.mu.
|
|
func (b *Bamboo) startTunnel(ip string) error {
|
|
addr, err := netip.ParseAddr(ip)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dev, err := wg.New(b.wgKey, addr, 0) // we dial out, so any local UDP port will do
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tcp, err := dev.ListenTCP(tunnel.ProxyTCPPort)
|
|
if err != nil {
|
|
dev.Close()
|
|
return err
|
|
}
|
|
udp, err := dev.ListenUDP(tunnel.ProxyUDPPort)
|
|
if err != nil {
|
|
dev.Close()
|
|
return err
|
|
}
|
|
b.tun, b.tunIP = dev, ip
|
|
go serveTCP(tcp)
|
|
go serveUDP(udp)
|
|
log.Printf("WireGuard up at %s", ip)
|
|
return nil
|
|
}
|
|
|
|
// syncPeers makes the tunnel match the edges hammerhead lists. Unchanged peers keep their sessions.
|
|
func (b *Bamboo) syncPeers() {
|
|
b.mu.Lock()
|
|
tun, edges := b.tun, append([]model.EdgeRef(nil), b.edges...)
|
|
b.mu.Unlock()
|
|
if tun == nil {
|
|
return
|
|
}
|
|
var peers []wg.Peer
|
|
for _, e := range edges {
|
|
if e.PublicKey == "" || e.IP == "" {
|
|
continue // that edge hasn't started yet
|
|
}
|
|
peers = append(peers, wg.Peer{PublicKey: e.PublicKey, AllowedIP: e.IP, Endpoint: e.Endpoint})
|
|
}
|
|
if err := tun.SetPeers(peers); err != nil {
|
|
log.Printf("peers: %v", err)
|
|
}
|
|
}
|
|
|
|
// refreshLoop re-resolves edge hostnames, so a changed DNS record is followed.
|
|
func (b *Bamboo) refreshLoop(ctx context.Context) {
|
|
t := time.NewTicker(60 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
b.syncPeers()
|
|
}
|
|
}
|
|
}
|
|
|
|
// connected lists the edges with a recent WireGuard handshake.
|
|
func (b *Bamboo) connected() []string {
|
|
b.mu.Lock()
|
|
tun, edges := b.tun, b.edges
|
|
b.mu.Unlock()
|
|
ids := []string{}
|
|
if tun == nil {
|
|
return ids
|
|
}
|
|
hs := tun.Handshakes()
|
|
for _, e := range edges {
|
|
if t, ok := hs[e.PublicKey]; ok && time.Since(t) < 3*time.Minute {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
}
|
|
sort.Strings(ids)
|
|
return ids
|
|
}
|
|
|
|
func (b *Bamboo) report() {
|
|
ids := b.connected()
|
|
var containers []model.Container
|
|
if b.cfg.Docker {
|
|
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
|
|
containers, _ = docker.List(ctx)
|
|
cancel()
|
|
}
|
|
var found []model.DiscoveredService
|
|
if b.disc != nil {
|
|
found = b.disc.Results()
|
|
}
|
|
b.mu.Lock()
|
|
msg, uid, stage, pct, ulog := b.updateMsg, b.updateID, b.updStage, b.updPct, append([]string(nil), b.updLog...)
|
|
b.mu.Unlock()
|
|
body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, Discovered: found, MailEgress: b.mailStatus(),
|
|
SSH: b.cfg.SSH, SSHRelay: true,
|
|
Version: b.cfg.Version, AutoUpdate: b.cfg.AutoUpdate, UpdateMsg: msg, UpdateID: uid, UpdateStage: stage, UpdatePct: pct, UpdateLog: ulog})
|
|
req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/site/report", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := b.http.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// reportLoop reports every 20 seconds, and straight away when the set of connected edges changes.
|
|
func (b *Bamboo) reportLoop(ctx context.Context) {
|
|
b.report()
|
|
slow := time.NewTicker(20 * time.Second)
|
|
fast := time.NewTicker(3 * time.Second)
|
|
defer slow.Stop()
|
|
defer fast.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-slow.C:
|
|
b.report()
|
|
case <-fast.C:
|
|
sig := strings.Join(b.connected(), ",")
|
|
b.mu.Lock()
|
|
changed := sig != b.lastRep
|
|
b.lastRep = sig
|
|
b.mu.Unlock()
|
|
if changed {
|
|
b.report()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- serving proxied connections ----------
|
|
|
|
func serveTCP(ln net.Listener) {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go handleStream(c)
|
|
}
|
|
}
|
|
|
|
func handleStream(st net.Conn) {
|
|
st.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
br := bufio.NewReader(st)
|
|
h, err := tunnel.ReadHeader(br)
|
|
st.SetReadDeadline(time.Time{})
|
|
if err != nil || h.Proto != "tcp" {
|
|
st.Close()
|
|
return
|
|
}
|
|
c, err := net.DialTimeout("tcp", net.JoinHostPort(h.Host, strconv.Itoa(h.Port)), 8*time.Second)
|
|
if err != nil {
|
|
st.Close() // the edge reads this as "target unreachable"
|
|
return
|
|
}
|
|
tunnel.Proxy(st, br, c, c)
|
|
}
|
|
|
|
type udpSession struct {
|
|
conn net.Conn
|
|
last time.Time
|
|
}
|
|
|
|
// serveUDP relays datagrams between the tunnel and UDP targets. Each datagram
|
|
// starts with a type byte: 0 = target header + payload (creates the session),
|
|
// 1 = payload for an existing session. Sessions are keyed by the edge's source port.
|
|
func serveUDP(pc net.PacketConn) {
|
|
var mu sync.Mutex
|
|
sessions := map[string]*udpSession{}
|
|
go func() {
|
|
t := time.NewTicker(15 * time.Second)
|
|
defer t.Stop()
|
|
for range t.C {
|
|
mu.Lock()
|
|
for _, s := range sessions {
|
|
if time.Since(s.last) > 60*time.Second {
|
|
s.conn.Close()
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
buf := make([]byte, 65535)
|
|
for {
|
|
n, from, err := pc.ReadFrom(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if n < 2 {
|
|
continue
|
|
}
|
|
key := from.String()
|
|
data := buf[:n]
|
|
var payload []byte
|
|
mu.Lock()
|
|
s := sessions[key]
|
|
switch data[0] {
|
|
case 0:
|
|
if len(data) < 3 {
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
hl := int(data[1])<<8 | int(data[2])
|
|
if len(data) < 3+hl {
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
payload = data[3+hl:]
|
|
if s == nil {
|
|
var h tunnel.Header
|
|
if json.Unmarshal(data[3:3+hl], &h) != nil || h.Proto != "udp" {
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
c, err := net.Dial("udp", net.JoinHostPort(h.Host, strconv.Itoa(h.Port)))
|
|
if err != nil {
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
s = &udpSession{conn: c}
|
|
sessions[key] = s
|
|
go func(s *udpSession, from net.Addr, key string) {
|
|
defer func() {
|
|
s.conn.Close()
|
|
mu.Lock()
|
|
if sessions[key] == s {
|
|
delete(sessions, key)
|
|
}
|
|
mu.Unlock()
|
|
}()
|
|
rb := make([]byte, 65535)
|
|
for {
|
|
n, err := s.conn.Read(rb)
|
|
if err != nil {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
s.last = time.Now()
|
|
mu.Unlock()
|
|
if _, err := pc.WriteTo(rb[:n], from); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}(s, from, key)
|
|
}
|
|
case 1:
|
|
payload = data[1:]
|
|
default:
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
if s != nil {
|
|
s.last = time.Now()
|
|
}
|
|
mu.Unlock()
|
|
if s != nil && len(payload) > 0 {
|
|
s.conn.Write(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
// requestUpdate acts on "update now" from Hammerhead (id identifies the request). It only ever installs
|
|
// what this machine's own auto-update would (the newest release from its configured server), and only
|
|
// when auto-update is on. Caller holds b.mu.
|
|
func (b *Bamboo) requestUpdate(id string) {
|
|
if id == b.updateID {
|
|
return // already handled, or in progress
|
|
}
|
|
b.updateID, b.updateMsg = id, ""
|
|
b.updStage, b.updPct, b.updLog = "starting", 0, nil
|
|
b.updNote("Update requested from the dashboard")
|
|
if !b.cfg.AutoUpdate || b.cfg.UpdateNow == nil {
|
|
b.updateMsg = "auto-update is off on this machine (set BAMBOO_AUTO_UPDATE=1 and restart it)"
|
|
b.updStage = "failed"
|
|
b.updNote("Refused: " + b.updateMsg)
|
|
go b.report()
|
|
return
|
|
}
|
|
if !b.updating.CompareAndSwap(false, true) {
|
|
b.updateMsg = "another update is already running"
|
|
b.updStage = "failed"
|
|
b.updNote("Refused: " + b.updateMsg)
|
|
go b.report()
|
|
return
|
|
}
|
|
go func() {
|
|
defer b.updating.Store(false)
|
|
tag, err := b.cfg.UpdateNow(b.ctx, b.updProgress) // on success this restarts the process and does not return
|
|
b.mu.Lock()
|
|
switch {
|
|
case err != nil:
|
|
b.updateMsg = "update failed: " + err.Error()
|
|
case tag == "":
|
|
b.updateMsg = "already on the newest release"
|
|
default:
|
|
b.updateMsg = "updated to " + tag + ", restarting"
|
|
}
|
|
b.mu.Unlock()
|
|
b.report()
|
|
}()
|
|
}
|
|
|
|
// updNote adds a timestamped line to the update log. Caller holds b.mu.
|
|
func (b *Bamboo) updNote(line string) {
|
|
b.updLog = append(b.updLog, time.Now().Format("15:04:05")+" "+line)
|
|
if len(b.updLog) > 200 {
|
|
b.updLog = b.updLog[len(b.updLog)-200:]
|
|
}
|
|
}
|
|
|
|
// updProgress is called by the updater as it works; Hammerhead gets a fresh report at most once a
|
|
// second (and straight away for every new log line), so its progress bar follows along.
|
|
func (b *Bamboo) updProgress(stage string, pct int, line string) {
|
|
b.mu.Lock()
|
|
b.updStage = stage
|
|
if pct >= 0 {
|
|
b.updPct = pct
|
|
}
|
|
if line != "" {
|
|
b.updNote(line)
|
|
}
|
|
send := line != "" || time.Since(b.updSent) > time.Second
|
|
if send {
|
|
b.updSent = time.Now()
|
|
}
|
|
b.mu.Unlock()
|
|
if send {
|
|
go b.report()
|
|
}
|
|
}
|