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>
525 lines
13 KiB
Go
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()
|
|
}
|
|
}
|