Files
Bamboo/bamboo.go
T
VeillaxandClaude Sonnet 5.5 710b101436 Add opt-in service discovery: local listeners and rate-limited private subnet scans
Reports identified services (HTTP, HTTPS, SSH, SMTP, FTP, ...) to Hammerhead as
suggested targets. Needs a Reef release containing model.DiscoveredService.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:56:46 -06:00

525 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
// 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
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)
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(),
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()
}
}