Add the Bamboo site agent
The site agent: a WireGuard peer that dials out to each edge, accepts TCP streams and native UDP datagrams inside the tunnel, lists local Docker containers, and runs the local SMTP relay for mail egress. Split out of the original single repository; depends on Reef for the shared types. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c93a1e4278
commit
2d4545e8cf
8 files changed
+976
-1
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# Copy to .env (never commit it). Real environment variables win over this file.
|
||||
|
||||
# Where Hammerhead is, and this site's token (Sites -> Show command).
|
||||
BAMBOO_HAMMERHEAD=https://hammerhead.example.com
|
||||
BAMBOO_TOKEN=
|
||||
|
||||
# Report local Docker containers so they can be picked as targets.
|
||||
#BAMBOO_DOCKER=1
|
||||
|
||||
# Folder for this agent's WireGuard key.
|
||||
#BAMBOO_DATA=./bamboo-data
|
||||
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
*-data/
|
||||
.env
|
||||
wg.key
|
||||
@@ -1,3 +1,45 @@
|
||||
# Bamboo
|
||||
|
||||
Bamboo runs inside your home or private network. It dials out to Blacktip over WireGuard, so it works behind NAT, and passes traffic to your real services. It can also list your Docker containers as targets and run a local mail relay for outgoing mail.
|
||||
Bamboo runs inside your home or private network. It dials out to Blacktip over WireGuard, so it works behind NAT, and passes traffic to your real services. It can also list your Docker containers as targets and run a local mail relay for outgoing mail.
|
||||
|
||||
Part of the sharks stack: [Hammerhead](https://git.veillax.com/Veillax/hammerhead) (control plane and dashboard) · [Blacktip](https://git.veillax.com/Veillax/blacktip) (edge) · **Bamboo** (site agent) · [Reef](https://git.veillax.com/Veillax/reef) (shared library). Everything is configured from Hammerhead's web dashboard; the full documentation lives in [its README](https://git.veillax.com/Veillax/hammerhead).
|
||||
|
||||
## Run it
|
||||
|
||||
In Hammerhead, go to Sites → add a site. The dashboard shows the exact command with your token. It looks like this:
|
||||
|
||||
```
|
||||
bamboo -hammerhead https://hammerhead.example.com -token <site token> -docker
|
||||
```
|
||||
|
||||
Bamboo only makes outbound connections (HTTPS to Hammerhead, and WireGuard UDP to each edge), so it needs no open ports and no port forwarding.
|
||||
|
||||
| Flag | Environment | Meaning |
|
||||
|---|---|---|
|
||||
| `-hammerhead` | `BAMBOO_HAMMERHEAD` | URL of Hammerhead |
|
||||
| `-token` | `BAMBOO_TOKEN` | the site's token |
|
||||
| `-data` | `BAMBOO_DATA` | folder for this agent's WireGuard key (default `./bamboo-data`) |
|
||||
| `-docker` | `BAMBOO_DOCKER=1` | report local Docker containers so they can be picked as targets |
|
||||
| `-env` | | `.env` file to read (default `./.env`) |
|
||||
|
||||
Settings and secrets can live in a `.env` file instead of the command line; see `.env.example`. Real environment variables always win.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Tunnel:** one WireGuard peer per edge, through a userspace network stack (no root, no kernel interface, no driver; works on Windows, macOS and Linux). Its private key never leaves this machine. TCP connections and native UDP datagrams are accepted from the edge and handed to your services.
|
||||
- **Docker discovery:** with `-docker`, lists running containers (and their ports and networks) so you can pick one as a target in the dashboard.
|
||||
- **Mail relay:** for a mail egress configured in the dashboard, opens a small SMTP relay on this machine (default `127.0.0.1:2525`). Point your mail server at it (Postfix: `relayhost = [127.0.0.1]:2525`) and each outgoing message leaves from the edge's public IP, so reverse DNS, SPF and your HELO name line up. Supports `AUTH PLAIN/LOGIN`, requires a password if it listens beyond `127.0.0.1`, relays one destination domain per transaction (extra domains get `452` so your server resends them separately), and passes your server's queue and retries through unchanged. 50 MB message limit.
|
||||
|
||||
## Build and test
|
||||
|
||||
```
|
||||
go env -w GOPRIVATE=git.veillax.com # once, so Go fetches straight from this server
|
||||
go build -o bin/ ./cmd/bamboo
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The end-to-end tests that run Bamboo together with Hammerhead and Blacktip live in the Hammerhead repository.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0. See `LICENSE`.
|
||||
@@ -0,0 +1,416 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
return &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{}}, 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)
|
||||
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.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()
|
||||
}
|
||||
body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, MailEgress: b.mailStatus()})
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Bamboo is the site agent: run it inside a private network to expose services from there.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
"git.veillax.com/Veillax/bamboo"
|
||||
"git.veillax.com/Veillax/reef/dotenv"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
// secrets (tokens, DNS-provider credentials) can live in a .env file instead of the command line
|
||||
envFile := dotenv.ArgPath(os.Args[1:], ".env")
|
||||
if _, err := dotenv.Load(envFile); err != nil {
|
||||
log.Fatalf("read %s: %v", envFile, err)
|
||||
}
|
||||
hh := flag.String("hammerhead", env("BAMBOO_HAMMERHEAD", ""), "URL of hammerhead")
|
||||
token := flag.String("token", env("BAMBOO_TOKEN", ""), "site token from the hammerhead UI")
|
||||
data := flag.String("data", env("BAMBOO_DATA", "./bamboo-data"), "directory for this agent's WireGuard key")
|
||||
dockerOn := flag.Bool("docker", os.Getenv("BAMBOO_DOCKER") == "1", "report local Docker containers so they can be picked as targets in the UI")
|
||||
flag.String("env", ".env", "file to read secrets and settings from")
|
||||
flag.Parse()
|
||||
if *hh == "" || *token == "" {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
b.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
module git.veillax.com/Veillax/bamboo
|
||||
|
||||
go 1.27.3
|
||||
|
||||
require git.veillax.com/Veillax/reef v0.1.0
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.4 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/net v0.61.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
||||
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
||||
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
package bamboo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"git.veillax.com/Veillax/reef/tunnel"
|
||||
)
|
||||
|
||||
// Mail egress: a small SMTP relay on this machine. Point your mail server's
|
||||
// relayhost at it (e.g. Postfix: relayhost = [127.0.0.1]:2525). Each message is
|
||||
// handed through the WireGuard tunnel to the edge, which delivers it to the
|
||||
// recipient's server from the edge's public IP and passes the replies back.
|
||||
//
|
||||
// One transaction is relayed to one destination domain. If a message has
|
||||
// recipients in several domains, the extra ones get "452 too many recipients"
|
||||
// and your MTA resends them in a separate transaction, which is standard behavior.
|
||||
|
||||
type mailRun struct {
|
||||
spec model.MailSpec
|
||||
ln net.Listener
|
||||
}
|
||||
|
||||
const maxRecipients = 100
|
||||
|
||||
// syncMail starts, restarts and stops relays to match specs.
|
||||
func (b *Bamboo) syncMail(specs []model.MailSpec) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
want := map[string]model.MailSpec{}
|
||||
for _, sp := range specs {
|
||||
want[sp.ID] = sp
|
||||
}
|
||||
for id, run := range b.mail {
|
||||
if sp, ok := want[id]; !ok || sp != run.spec {
|
||||
run.ln.Close()
|
||||
delete(b.mail, id)
|
||||
delete(b.mailErr, id)
|
||||
}
|
||||
}
|
||||
for id, sp := range want {
|
||||
if _, running := b.mail[id]; running {
|
||||
continue
|
||||
}
|
||||
ln, err := net.Listen("tcp", sp.Listen)
|
||||
if err != nil {
|
||||
b.mailErr[id] = err.Error()
|
||||
log.Printf("mail egress %s: %v", sp.Listen, err)
|
||||
continue
|
||||
}
|
||||
run := &mailRun{spec: sp, ln: ln}
|
||||
b.mail[id] = run
|
||||
b.mailErr[id] = ""
|
||||
log.Printf("mail egress relay listening on %s (via edge %s)", sp.Listen, sp.EdgeID)
|
||||
go b.serveMail(run)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bamboo) mailStatus() map[string]string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
out := map[string]string{}
|
||||
for id, e := range b.mailErr {
|
||||
out[id] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Bamboo) serveMail(r *mailRun) {
|
||||
for {
|
||||
c, err := r.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go b.handleSMTP(c, r.spec)
|
||||
}
|
||||
}
|
||||
|
||||
type smtpSession struct {
|
||||
b *Bamboo
|
||||
c net.Conn
|
||||
tp *textproto.Conn
|
||||
spec model.MailSpec
|
||||
authed bool
|
||||
|
||||
hasFrom bool
|
||||
from string
|
||||
domain string
|
||||
rcpts int
|
||||
|
||||
stream net.Conn
|
||||
sr *bufio.Reader
|
||||
}
|
||||
|
||||
func (s *smtpSession) say(code int, text string) {
|
||||
text = strings.NewReplacer("\r", " ", "\n", " ").Replace(text)
|
||||
fmt.Fprintf(s.c, "%d %s\r\n", code, text)
|
||||
}
|
||||
|
||||
func (s *smtpSession) sayMulti(code int, lines ...string) {
|
||||
for i, l := range lines {
|
||||
sep := "-"
|
||||
if i == len(lines)-1 {
|
||||
sep = " "
|
||||
}
|
||||
fmt.Fprintf(s.c, "%d%s%s\r\n", code, sep, l)
|
||||
}
|
||||
}
|
||||
|
||||
// openStream connects to the edge's mail port through the tunnel.
|
||||
func (s *smtpSession) openStream() error {
|
||||
b := s.b
|
||||
b.mu.Lock()
|
||||
tun, edgeIP := b.tun, ""
|
||||
for _, e := range b.edges {
|
||||
if e.ID == s.spec.EdgeID {
|
||||
edgeIP = e.IP
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if tun == nil || edgeIP == "" {
|
||||
return errors.New("the tunnel to the edge isn't up")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(b.ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
c, err := tun.DialContext(ctx, "tcp", net.JoinHostPort(edgeIP, strconv.Itoa(tunnel.MailTCPPort)))
|
||||
if err != nil {
|
||||
return errors.New("can't reach the edge through the tunnel")
|
||||
}
|
||||
s.stream, s.sr = c, bufio.NewReader(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *smtpSession) closeStream() {
|
||||
if s.stream != nil {
|
||||
s.stream.Close()
|
||||
s.stream, s.sr = nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *smtpSession) resetTxn() {
|
||||
s.closeStream()
|
||||
s.hasFrom, s.from, s.domain, s.rcpts = false, "", "", 0
|
||||
}
|
||||
|
||||
// ask sends one request to the edge and reads its reply.
|
||||
func (s *smtpSession) ask(m tunnel.MailMsg, body []byte) (tunnel.MailReply, error) {
|
||||
var rep tunnel.MailReply
|
||||
line, _ := json.Marshal(m)
|
||||
s.stream.SetDeadline(time.Now().Add(4 * time.Minute))
|
||||
if _, err := s.stream.Write(append(line, '\n')); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if body != nil {
|
||||
if _, err := s.stream.Write(body); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
}
|
||||
resp, err := s.sr.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
return rep, json.Unmarshal(resp, &rep)
|
||||
}
|
||||
|
||||
// angle extracts the address from "FROM:<a@b> SIZE=1" or "TO:<a@b>".
|
||||
func angle(arg, key string) (addr, params string, ok bool) {
|
||||
if len(arg) < len(key) || !strings.EqualFold(arg[:len(key)], key) {
|
||||
return "", "", false
|
||||
}
|
||||
rest := strings.TrimSpace(arg[len(key):])
|
||||
if !strings.HasPrefix(rest, "<") {
|
||||
return "", "", false
|
||||
}
|
||||
end := strings.IndexByte(rest, '>')
|
||||
if end < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return rest[1:end], strings.TrimSpace(rest[end+1:]), true
|
||||
}
|
||||
|
||||
func (b *Bamboo) handleSMTP(c net.Conn, spec model.MailSpec) {
|
||||
s := &smtpSession{b: b, c: c, tp: textproto.NewConn(c), spec: spec, authed: spec.Username == ""}
|
||||
defer func() {
|
||||
recover()
|
||||
s.closeStream()
|
||||
c.Close()
|
||||
}()
|
||||
s.say(220, "mail-egress ESMTP ready")
|
||||
badAuth := 0
|
||||
for {
|
||||
c.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||
line, err := s.tp.ReadLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd, arg, _ := strings.Cut(line, " ")
|
||||
arg = strings.TrimSpace(arg)
|
||||
switch strings.ToUpper(cmd) {
|
||||
case "EHLO", "HELO":
|
||||
lines := []string{"mail-egress at your service", "SIZE " + strconv.Itoa(tunnel.MaxMessage), "8BITMIME", "SMTPUTF8", "ENHANCEDSTATUSCODES"}
|
||||
if spec.Username != "" {
|
||||
lines = append(lines, "AUTH PLAIN LOGIN")
|
||||
}
|
||||
if strings.ToUpper(cmd) == "HELO" {
|
||||
s.say(250, "mail-egress")
|
||||
} else {
|
||||
s.sayMulti(250, lines...)
|
||||
}
|
||||
case "AUTH":
|
||||
if spec.Username == "" {
|
||||
s.say(503, "5.5.1 authentication isn't needed here")
|
||||
continue
|
||||
}
|
||||
if !s.auth(arg) {
|
||||
s.say(535, "5.7.8 authentication failed")
|
||||
if badAuth++; badAuth >= 3 {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.authed = true
|
||||
s.say(235, "2.7.0 authenticated")
|
||||
case "MAIL":
|
||||
if !s.authed {
|
||||
s.say(530, "5.7.0 authentication required")
|
||||
continue
|
||||
}
|
||||
if s.hasFrom {
|
||||
s.say(503, "5.5.1 a transaction is already in progress")
|
||||
continue
|
||||
}
|
||||
addr, params, ok := angle(arg, "FROM:")
|
||||
if !ok {
|
||||
s.say(501, "5.1.7 syntax: MAIL FROM:<address>")
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(params), "SIZE=") {
|
||||
for _, p := range strings.Fields(params) {
|
||||
if strings.HasPrefix(strings.ToUpper(p), "SIZE=") {
|
||||
if n, _ := strconv.Atoi(p[5:]); n > tunnel.MaxMessage {
|
||||
s.say(552, "5.3.4 message too large")
|
||||
addr = "\x00"
|
||||
}
|
||||
}
|
||||
}
|
||||
if addr == "\x00" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
s.hasFrom, s.from = true, addr
|
||||
s.say(250, "2.1.0 ok")
|
||||
case "RCPT":
|
||||
if !s.hasFrom {
|
||||
s.say(503, "5.5.1 send MAIL FROM first")
|
||||
continue
|
||||
}
|
||||
addr, _, ok := angle(arg, "TO:")
|
||||
if !ok || addr == "" {
|
||||
s.say(501, "5.1.3 syntax: RCPT TO:<address>")
|
||||
continue
|
||||
}
|
||||
s.rcpt(addr)
|
||||
case "DATA":
|
||||
s.data()
|
||||
case "RSET":
|
||||
if s.stream != nil {
|
||||
s.ask(tunnel.MailMsg{Op: "rset"}, nil)
|
||||
}
|
||||
s.resetTxn()
|
||||
s.say(250, "2.0.0 ok")
|
||||
case "NOOP":
|
||||
s.say(250, "2.0.0 ok")
|
||||
case "QUIT":
|
||||
s.say(221, "2.0.0 bye")
|
||||
return
|
||||
case "VRFY", "EXPN":
|
||||
s.say(252, "2.5.2 cannot verify, but will accept and attempt delivery")
|
||||
case "STARTTLS":
|
||||
s.say(502, "5.5.1 TLS isn't offered: this relay is meant to be reached on this machine")
|
||||
default:
|
||||
s.say(500, "5.5.2 command not recognized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *smtpSession) auth(arg string) bool {
|
||||
mech, initial, _ := strings.Cut(arg, " ")
|
||||
read := func() string { // one base64 line from the client
|
||||
l, err := s.tp.ReadLine()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, _ := base64.StdEncoding.DecodeString(l)
|
||||
return string(b)
|
||||
}
|
||||
var user, pass string
|
||||
switch strings.ToUpper(mech) {
|
||||
case "PLAIN":
|
||||
raw := ""
|
||||
if initial != "" {
|
||||
b, _ := base64.StdEncoding.DecodeString(initial)
|
||||
raw = string(b)
|
||||
} else {
|
||||
s.say(334, "")
|
||||
raw = read()
|
||||
}
|
||||
parts := strings.Split(raw, "\x00") // authzid \0 user \0 password
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
user, pass = parts[1], parts[2]
|
||||
case "LOGIN":
|
||||
s.say(334, base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
user = read()
|
||||
s.say(334, base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
pass = read()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
okU := subtle.ConstantTimeCompare([]byte(user), []byte(s.spec.Username)) == 1
|
||||
okP := subtle.ConstantTimeCompare([]byte(pass), []byte(s.spec.Password)) == 1
|
||||
return okU && okP
|
||||
}
|
||||
|
||||
func (s *smtpSession) rcpt(addr string) {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at < 1 || at == len(addr)-1 {
|
||||
s.say(501, "5.1.3 bad recipient address")
|
||||
return
|
||||
}
|
||||
domain := strings.ToLower(addr[at+1:])
|
||||
if s.rcpts > 0 && domain != s.domain {
|
||||
s.say(452, "4.5.3 one destination domain per transaction; send the others separately")
|
||||
return
|
||||
}
|
||||
if s.rcpts >= maxRecipients {
|
||||
s.say(452, "4.5.3 too many recipients")
|
||||
return
|
||||
}
|
||||
first := s.stream == nil
|
||||
if first {
|
||||
if err := s.openStream(); err != nil {
|
||||
s.say(451, "4.4.1 "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
rep, err := s.ask(tunnel.MailMsg{Op: "rcpt", Egress: s.spec.ID, From: s.from, To: addr}, nil)
|
||||
if err != nil {
|
||||
s.closeStream()
|
||||
s.say(451, "4.4.2 lost contact with the edge")
|
||||
return
|
||||
}
|
||||
if rep.Code != 250 {
|
||||
if first {
|
||||
s.closeStream()
|
||||
}
|
||||
s.say(rep.Code, rep.Text)
|
||||
return
|
||||
}
|
||||
s.rcpts++
|
||||
s.domain = domain
|
||||
s.say(250, "2.1.5 ok")
|
||||
}
|
||||
|
||||
func (s *smtpSession) data() {
|
||||
if s.rcpts == 0 || s.stream == nil {
|
||||
s.say(503, "5.5.1 need a recipient first")
|
||||
return
|
||||
}
|
||||
rep, err := s.ask(tunnel.MailMsg{Op: "data"}, nil)
|
||||
if err != nil {
|
||||
s.resetTxn()
|
||||
s.say(451, "4.4.2 lost contact with the edge")
|
||||
return
|
||||
}
|
||||
if rep.Code != 354 {
|
||||
s.say(rep.Code, rep.Text)
|
||||
s.resetTxn()
|
||||
return
|
||||
}
|
||||
s.say(354, "end data with <CR><LF>.<CR><LF>")
|
||||
s.c.SetReadDeadline(time.Now().Add(10 * time.Minute))
|
||||
dr := s.tp.DotReader()
|
||||
body, err := io.ReadAll(io.LimitReader(dr, tunnel.MaxMessage+1))
|
||||
if err != nil {
|
||||
s.resetTxn()
|
||||
return
|
||||
}
|
||||
if len(body) > tunnel.MaxMessage {
|
||||
io.Copy(io.Discard, dr)
|
||||
s.ask(tunnel.MailMsg{Op: "rset"}, nil)
|
||||
s.resetTxn()
|
||||
s.say(552, "5.3.4 message too large")
|
||||
return
|
||||
}
|
||||
rep, err = s.ask(tunnel.MailMsg{Op: "body", Len: len(body)}, body)
|
||||
s.resetTxn()
|
||||
if err != nil {
|
||||
// we can't know whether the far side accepted it; a temporary failure
|
||||
// makes the MTA retry, which risks a duplicate but never loses mail
|
||||
s.say(451, "4.4.2 lost contact with the edge while sending")
|
||||
return
|
||||
}
|
||||
s.say(rep.Code, rep.Text)
|
||||
}
|
||||
Reference in new issue
Block a user