Add the shared packages for the sharks stack

model, tunnel, wg, authtoken, netguard, docker and dotenv, split out of the
original single repository so Hammerhead, Blacktip and Bamboo can live in
their own repos and import them. README extended; LICENSE unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 02:41:44 -06:00
1 parent c7c2ae8f8d
commit 5177093360
16 files changed
+1358

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.env
*.db
*.db-wal
*.db-shm
master.key
+31
View File
@@ -1,3 +1,34 @@
# Reef # Reef
A shared library for Hammerhead, Blacktip, and Bamboo A shared library for Hammerhead, Blacktip, and Bamboo
Shared types and building blocks for the sharks stack: [Hammerhead](https://git.veillax.com/Veillax/hammerhead) (control plane), [Blacktip](https://git.veillax.com/Veillax/blacktip) (edge) and [Bamboo](https://git.veillax.com/Veillax/bamboo) (site agent).
Reef holds the code all three programs agree on, so each can be built and released from its own repository.
| Package | What it is |
|---|---|
| `model` | The data types passed between the programs: edges, sites, ports, mail egress, DKIM keys, alert-relevant status and reports |
| `tunnel` | The wire format used inside the WireGuard tunnel, and the helper that pipes two connections together |
| `wg` | wireguard-go with a userspace network stack: no root, no kernel interface, works on Windows, macOS and Linux |
| `authtoken` | Signed, expiring login tokens with a revocation epoch |
| `netguard` | Refuses internal addresses for anything that connects out on a user's behalf (webhooks, mail delivery), and dials the address it checked |
| `docker` | Lists running containers through the Docker Engine API, with no SDK |
| `dotenv` | Loads a `.env` file into the environment without overriding real variables |
## Using it
```
go env -w GOPRIVATE=git.veillax.com # once, so Go fetches straight from this server
go get git.veillax.com/Veillax/reef@latest
```
## Tests
```
go test ./...
```
## License
AGPL-3.0. See `LICENSE`.
+58
View File
@@ -0,0 +1,58 @@
// Package authtoken signs the short tokens hammerhead hands to visitors and blacktip verifies.
package authtoken
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"strings"
"time"
)
const (
KindCallback = "cb" // minted by hammerhead after a successful login, valid for minutes
KindSession = "sess" // minted by blacktip for the visitor's cookie
)
type Token struct {
Mapping string `json:"m"`
Sub string `json:"sub"` // who: an email, "password", "pin", "share:name"
Kind string `json:"k"`
Exp int64 `json:"exp"`
// Epoch is the resource's login generation when this was issued. Hammerhead
// bumps it to sign every visitor out (a member removed, a password changed);
// tokens from an older epoch are then refused without any per-token state.
Epoch int64 `json:"ep"`
}
var enc = base64.RawURLEncoding
func mac(key, payload string) string {
m := hmac.New(sha256.New, []byte(key))
m.Write([]byte(payload))
return enc.EncodeToString(m.Sum(nil))
}
func Sign(key string, t Token) string {
b, _ := json.Marshal(t)
p := enc.EncodeToString(b)
return p + "." + mac(key, p)
}
// Verify checks the signature, kind, mapping, epoch and expiry.
func Verify(key, tok, kind, mapping string, epoch int64) (Token, bool) {
var t Token
p, sig, found := strings.Cut(tok, ".")
if !found || !hmac.Equal([]byte(sig), []byte(mac(key, p))) {
return t, false
}
b, err := enc.DecodeString(p)
if err != nil || json.Unmarshal(b, &t) != nil {
return t, false
}
if t.Kind != kind || t.Mapping != mapping || t.Epoch != epoch || time.Now().Unix() > t.Exp {
return t, false
}
return t, true
}
+116
View File
@@ -0,0 +1,116 @@
// Package docker lists running containers through the Docker Engine API so agents
// can offer them as targets. It speaks plain HTTP over the engine's socket, with
// no dependency on the Docker SDK.
package docker
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"runtime"
"sort"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
)
func host() string {
if h := os.Getenv("DOCKER_HOST"); h != "" {
return h
}
if runtime.GOOS == "windows" {
return "npipe:////./pipe/docker_engine"
}
return "unix:///var/run/docker.sock"
}
func dialer(h string) (func(ctx context.Context, network, addr string) (net.Conn, error), error) {
u, err := url.Parse(h)
if err != nil {
return nil, err
}
switch u.Scheme {
case "unix":
path := u.Path
return func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", path)
}, nil
case "tcp", "http":
addr := u.Host
return func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "tcp", addr)
}, nil
case "npipe":
return dialPipe(strings.ReplaceAll(u.Path, "/", `\`)), nil
}
return nil, fmt.Errorf("unsupported DOCKER_HOST %q", h)
}
// List returns the running containers, or an error if Docker isn't reachable.
func List(ctx context.Context) ([]model.Container, error) {
dial, err := dialer(host())
if err != nil {
return nil, err
}
c := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: dial, DisableKeepAlives: true}}
req, _ := http.NewRequestWithContext(ctx, "GET", "http://docker/containers/json", nil)
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("docker returned %d", resp.StatusCode)
}
var raw []struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string
State string
Ports []struct {
PrivatePort int
PublicPort int
Type string
}
NetworkSettings struct {
Networks map[string]struct{ IPAddress string }
}
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
out := []model.Container{}
for _, r := range raw {
c := model.Container{ID: r.ID, Image: r.Image, State: r.State, Ports: []model.ContainerPort{}, Networks: map[string]string{}}
if len(c.ID) > 12 {
c.ID = c.ID[:12]
}
if len(r.Names) > 0 {
c.Name = strings.TrimPrefix(r.Names[0], "/")
}
seen := map[string]bool{}
for _, p := range r.Ports {
key := fmt.Sprintf("%d/%d/%s", p.PrivatePort, p.PublicPort, p.Type)
if seen[key] {
continue // docker lists IPv4 and IPv6 bindings separately
}
seen[key] = true
c.Ports = append(c.Ports, model.ContainerPort{Private: p.PrivatePort, Public: p.PublicPort, Type: p.Type})
}
for n, v := range r.NetworkSettings.Networks {
if v.IPAddress != "" {
c.Networks[n] = v.IPAddress
}
}
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
+48
View File
@@ -0,0 +1,48 @@
package docker
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestListParsesContainers(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
w.Write([]byte(`[
{"Id":"abcdef1234567890","Names":["/minecraft"],"Image":"itzg/minecraft-server","State":"running",
"Ports":[{"PrivatePort":25565,"PublicPort":25565,"Type":"tcp"},{"PrivatePort":25565,"PublicPort":25565,"Type":"tcp"},{"PrivatePort":8123,"Type":"tcp"}],
"NetworkSettings":{"Networks":{"bridge":{"IPAddress":"172.17.0.2"},"none":{"IPAddress":""}}}},
{"Id":"1111","Names":["/db"],"Image":"postgres","State":"running","Ports":[],"NetworkSettings":{"Networks":{}}}
]`))
}))
defer srv.Close()
t.Setenv("DOCKER_HOST", "tcp://"+strings.TrimPrefix(srv.URL, "http://"))
got, err := List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Name != "db" || got[1].Name != "minecraft" {
t.Fatalf("unexpected list: %+v", got)
}
mc := got[1]
if mc.ID != "abcdef123456" || len(mc.Ports) != 2 || mc.Ports[0].Public != 25565 || mc.Ports[1].Public != 0 {
t.Errorf("ports/id wrong: %+v", mc)
}
if mc.Networks["bridge"] != "172.17.0.2" || len(mc.Networks) != 1 {
t.Errorf("networks wrong: %+v", mc.Networks)
}
}
func TestListUnreachable(t *testing.T) {
t.Setenv("DOCKER_HOST", "tcp://127.0.0.1:1")
if _, err := List(context.Background()); err == nil {
t.Fatal("expected an error when docker is not running")
}
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows
package docker
import (
"context"
"errors"
"net"
)
func dialPipe(string) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("named pipes are only available on Windows")
}
}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package docker
import (
"context"
"net"
"github.com/Microsoft/go-winio"
)
func dialPipe(path string) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, _, _ string) (net.Conn, error) {
return winio.DialPipeContext(ctx, path)
}
}
+89
View File
@@ -0,0 +1,89 @@
// Package dotenv loads KEY=VALUE pairs from a .env file into the process
// environment, so secrets can live in one file that is kept out of version
// control instead of on command lines.
//
// Real environment variables always win over the file, so a deployment can
// still override anything with its own environment.
package dotenv
import (
"bufio"
"errors"
"os"
"strings"
)
// Load reads the first of paths that exists and sets any variables it defines
// that aren't already set. It returns the path used, or "" if none existed.
func Load(paths ...string) (string, error) {
for _, p := range paths {
if p == "" {
continue
}
f, err := os.Open(p)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return "", err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 1<<20)
for sc.Scan() {
k, v, ok := parseLine(sc.Text())
if !ok {
continue
}
if _, set := os.LookupEnv(k); !set {
os.Setenv(k, v)
}
}
return p, sc.Err()
}
return "", nil
}
// ArgPath finds -env <path> / --env=<path> in args, so the file can be chosen
// before the program's own flags are parsed (flags take their defaults from the environment).
func ArgPath(args []string, def string) string {
for i, a := range args {
a = strings.TrimLeft(a, "-")
switch {
case a == "env" && i+1 < len(args):
return args[i+1]
case strings.HasPrefix(a, "env="):
return a[len("env="):]
}
}
return def
}
func parseLine(line string) (key, val string, ok bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
k, v, found := strings.Cut(line, "=")
k = strings.TrimSpace(k)
if !found || k == "" || strings.ContainsAny(k, " \t") {
return "", "", false
}
v = strings.TrimSpace(v)
switch {
case len(v) >= 2 && v[0] == '"':
if end := strings.LastIndexByte(v, '"'); end > 0 {
v = strings.NewReplacer(`\n`, "\n", `\r`, "\r", `\t`, "\t", `\"`, `"`, `\\`, `\`).Replace(v[1:end])
}
case len(v) >= 2 && v[0] == '\'':
if end := strings.LastIndexByte(v, '\''); end > 0 {
v = v[1:end]
}
default:
if i := strings.Index(v, " #"); i >= 0 { // trailing comment
v = strings.TrimSpace(v[:i])
}
}
return k, v, true
}
+61
View File
@@ -0,0 +1,61 @@
package dotenv
import (
"os"
"path/filepath"
"testing"
)
func TestLoadParsesAndNeverOverridesTheRealEnvironment(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
os.WriteFile(path, []byte(`# a comment
PLAIN=value one
export EXPORTED=yes
QUOTED="has # hash and \"quotes\"\nnewline"
SINGLE='no $expansion \n here'
TRAILING=kept # dropped
EMPTY=
SPACED = trimmed
BAD LINE
=novalue
ALREADY=from-file
`), 0o600)
t.Setenv("ALREADY", "from-environment")
for _, k := range []string{"PLAIN", "EXPORTED", "QUOTED", "SINGLE", "TRAILING", "EMPTY", "SPACED"} {
os.Unsetenv(k)
defer os.Unsetenv(k)
}
used, err := Load(filepath.Join(dir, "missing.env"), path)
if err != nil || used != path {
t.Fatalf("Load = %q, %v", used, err)
}
want := map[string]string{
"PLAIN": "value one", "EXPORTED": "yes", "QUOTED": "has # hash and \"quotes\"\nnewline",
"SINGLE": `no $expansion \n here`, "TRAILING": "kept", "EMPTY": "", "SPACED": "trimmed", "ALREADY": "from-environment",
}
for k, v := range want {
if got, ok := os.LookupEnv(k); !ok || got != v {
t.Errorf("%s = %q (set=%v), want %q", k, got, ok, v)
}
}
if used, _ := Load(filepath.Join(dir, "nope")); used != "" {
t.Errorf("a missing file should load nothing, got %q", used)
}
}
func TestArgPath(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
{[]string{"-listen", ":1"}, ".env"},
{[]string{"-env", "/etc/x.env"}, "/etc/x.env"},
{[]string{"--env=/a/b"}, "/a/b"},
{[]string{"-env"}, ".env"},
} {
if got := ArgPath(tc.args, ".env"); got != tc.want {
t.Errorf("ArgPath(%v) = %q, want %q", tc.args, got, tc.want)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
module git.veillax.com/Veillax/reef
go 1.27.3
require (
github.com/Microsoft/go-winio v0.6.4
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee
)
require (
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
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
)
+18
View File
@@ -0,0 +1,18 @@
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=
+327
View File
@@ -0,0 +1,327 @@
// Package model holds the types shared by hammerhead, blacktip and bamboo.
package model
import "time"
const (
ProtoTCP = "tcp" // raw TCP on ip:port
ProtoUDP = "udp" // raw UDP on ip:port
ProtoHTTP = "http" // reverse proxy, routed by Host header (many hostnames per ip:port)
ProtoHTTPS = "https" // reverse proxy with automatic Let's Encrypt certificates
ProtoTLS = "tls" // TLS passthrough, routed by SNI (many hostnames per ip:port)
)
// LastPort is the last public port a mapping occupies (Port itself unless it is a range).
func (m Mapping) LastPort() int {
if m.PortEnd > m.Port {
return m.PortEnd
}
return m.Port
}
// HostBased reports whether mappings of this protocol are routed by hostname.
func HostBased(proto string) bool {
return proto == ProtoHTTP || proto == ProtoHTTPS || proto == ProtoTLS
}
// Web reports whether the protocol is served by the HTTP reverse proxy.
func Web(proto string) bool { return proto == ProtoHTTP || proto == ProtoHTTPS }
// Edge is a blacktip node: a public server that owns one or more IP addresses.
type Edge struct {
ID string `json:"id"`
Name string `json:"name"`
Token string `json:"token,omitempty"`
AuthKey string `json:"authKey,omitempty"` // signs resource-auth tokens; never sent to browsers
TunnelAddr string `json:"tunnelAddr"` // public host:port (UDP) of the edge's WireGuard endpoint
TunnelIP string `json:"tunnelIp"` // the edge's address inside the tunnel network
PublicKey string `json:"publicKey"` // WireGuard public key, reported by the edge
OrgIDs []string `json:"orgIds"` // orgs allowed to use this edge; empty = all
IPs []string `json:"ips"` // addresses the edge reported it can bind
LastSeen time.Time `json:"lastSeen"`
}
// Site is a bamboo agent: a private network that holds the real services.
type Site struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
Token string `json:"token,omitempty"`
Connected []string `json:"connected"` // edge IDs the agent currently has tunnels to
LastSeen time.Time `json:"lastSeen"`
// Local sites are created automatically with each edge. Mappings that use
// one are dialed directly from the edge machine, so no bamboo is needed.
Local bool `json:"local"`
EdgeID string `json:"edgeId,omitempty"` // the edge a local site belongs to
TunnelIP string `json:"tunnelIp,omitempty"` // the site's address inside the tunnel network
PublicKey string `json:"publicKey,omitempty"` // WireGuard public key, reported by the agent
}
// Domain is a base domain an org owns. Hostnames of web/tls mappings must sit under one.
type Domain struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
EdgeID string `json:"edgeId"`
IP string `json:"ip"`
Note string `json:"note"`
}
// Target is one backend a mapping forwards to.
type Target struct {
SiteID string `json:"siteId"`
Host string `json:"host"`
Port int `json:"port"`
}
// Auth is the access policy in front of a web mapping. Secrets (password, PIN)
// live only in hammerhead; these flags say which methods are switched on.
type Auth struct {
SSO bool `json:"sso"` // sign in with a hammerhead account
Password bool `json:"password"` // shared password
Pin bool `json:"pin"` // 4-12 digit PIN
Email bool `json:"email"` // one-time code emailed to an allowed address
AllowedRoles []string `json:"allowedRoles"`
AllowedUsers []string `json:"allowedUsers"`
AllowedMails []string `json:"allowedMails"` // addresses allowed for email codes ("*@example.com" ok)
SessionHours int `json:"sessionHours"` // 0 = 24
}
// Protected reports whether visitors must authenticate.
func (a Auth) Protected() bool { return a.SSO || a.Password || a.Pin || a.Email }
// Rule is evaluated before authentication, top to bottom.
type Rule struct {
Action string `json:"action"` // "allow" (skip auth) or "deny"
Match string `json:"match"` // "path" (prefix) or "ip" (address or CIDR)
Value string `json:"value"`
}
// Mapping opens one public port (or a hostname on a shared port) and sends it to one or more targets.
type Mapping struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
Proto string `json:"proto"`
EdgeID string `json:"edgeId"`
BindIP string `json:"bindIp"` // 0.0.0.0 = every address on the edge
Port int `json:"port"`
PortEnd int `json:"portEnd,omitempty"` // tcp/udp only: Port..PortEnd are all opened, each sent to the target port shifted by the same offset
Hostname string `json:"hostname,omitempty"` // web/tls only; "*.example.com" allowed
Targets []Target `json:"targets"` // udp uses only the first
Enabled bool `json:"enabled"`
// ProxyProtocol ("v1" or "v2") makes the edge start each tcp/tls connection to the
// target with a PROXY protocol header carrying the real client address.
ProxyProtocol string `json:"proxyProtocol,omitempty"`
// web (http/https) options
Auth Auth `json:"auth"`
AuthEpoch int64 `json:"authEpoch"` // bumped to sign every visitor out; set by hammerhead only
Rules []Rule `json:"rules"`
RedirectHTTP bool `json:"redirectHttp"` // https: also answer on port 80 with a redirect
BackendHTTPS bool `json:"backendHttps"` // talk TLS (unverified) to the targets
RewriteHost string `json:"rewriteHost"` // Host header sent to the target; "" keeps the original
HealthPath string `json:"healthPath"` // http(s): GET this path; "" = TCP connect check
HealthSeconds int `json:"healthSeconds"` // 0 = 30; -1 = off
}
// MailEgress lets a mail server at a site send its outgoing mail from an edge's
// public IP. Bamboo runs a small SMTP relay on the site's machine; each message
// travels through the WireGuard tunnel and Blacktip delivers it straight to the
// recipient's mail server (found by MX lookup) from SourceIP, introducing itself
// as HeloName. Reverse DNS, SPF and the HELO name can then all agree, which is
// what receiving servers check.
type MailEgress struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
SiteID string `json:"siteId"`
EdgeID string `json:"edgeId"`
Listen string `json:"listen"` // host:port of the SMTP relay on the site's machine
SourceIP string `json:"sourceIp"` // edge address mail leaves from (its PTR should match HeloName)
HeloName string `json:"heloName"` // name announced to receiving servers, e.g. mail.example.com
Smarthost string `json:"smarthost"` // optional host:port: relay through this server instead of looking up MX
RequireTLS bool `json:"requireTls"` // refuse to send to servers that don't offer verifiable STARTTLS
RatePerMinute int `json:"ratePerMinute"` // messages per minute the edge will send; 0 = 60
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Enabled bool `json:"enabled"`
}
// MailSpec is what a site agent needs to run the SMTP relay.
type MailSpec struct {
ID string `json:"id"`
EdgeID string `json:"edgeId"`
Listen string `json:"listen"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
// DKIMKey is a signing key for one sending domain. The edge signs outgoing mail
// with it; the public half goes in DNS at <selector>._domainkey.<domain>.
type DKIMKey struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Domain string `json:"domain"`
Selector string `json:"selector"`
PrivateKey string `json:"privateKey,omitempty"` // PEM, never sent to browsers
PublicKey string `json:"publicKey"` // base64 DER, the p= value of the TXT record
Created time.Time `json:"created"`
}
// DKIMSigner is what an edge needs to sign mail for one domain.
type DKIMSigner struct {
KeyID string `json:"keyId"`
Domain string `json:"domain"`
Selector string `json:"selector"`
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
}
// MailPolicy is what an edge needs to deliver mail for one MailEgress.
type MailPolicy struct {
ID string `json:"id"`
SiteID string `json:"siteId"`
SourceIP string `json:"sourceIp"`
HeloName string `json:"heloName"`
Smarthost string `json:"smarthost"`
RequireTLS bool `json:"requireTls"`
RatePerMinute int `json:"ratePerMinute"`
DKIM []DKIMSigner `json:"dkim"` // keys for the org's sending domains
}
// MailCheck is the edge's report on how deliverable mail from a source IP is.
type MailCheck struct {
CheckedAt time.Time `json:"checkedAt"`
PTR []string `json:"ptr"` // reverse DNS names of the source IP
PTRMatchesHelo bool `json:"ptrMatchesHelo"` // one of them equals the HELO name
HeloResolves bool `json:"heloResolves"` // the HELO name resolves back to the source IP
Port25 string `json:"port25"` // "open" or why not
DKIM map[string]string `json:"dkim"` // key ID -> "published", "missing" or "mismatch"
}
// ---------- edge <-> hammerhead ----------
// EdgeConfig is what blacktip long-polls from hammerhead.
type EdgeConfig struct {
Version int64 `json:"version"`
EdgeID string `json:"edgeId"`
AuthKey string `json:"authKey"`
Dashboard string `json:"dashboard"` // public URL of hammerhead, for login redirects
Mappings []Mapping `json:"mappings"`
TunnelIP string `json:"tunnelIp"` // the edge's WireGuard address
Peers []WGPeer `json:"peers"` // sites the edge may reach through the tunnel
Local []string `json:"local"` // site IDs the edge dials directly on its own machine
AcmeEmail string `json:"acmeEmail"`
AcmeStages bool `json:"acmeStaging"`
MailEgress []MailPolicy `json:"mailEgress"`
}
// WGPeer is a site as seen from an edge.
type WGPeer struct {
SiteID string `json:"siteId"`
PublicKey string `json:"publicKey"`
IP string `json:"ip"`
}
// TargetHealth is the latest check result for one target.
type TargetHealth struct {
Healthy bool `json:"healthy"`
Detail string `json:"detail,omitempty"`
}
// MappingStatus is what an edge reports per mapping.
type MappingStatus struct {
Error string `json:"error"` // "" = listening
Cert string `json:"cert,omitempty"` // https: certificate state
Targets []TargetHealth `json:"targets,omitempty"`
}
// Stat is traffic since the last report.
type Stat struct {
Conns int64 `json:"conns"`
BytesIn int64 `json:"bytesIn"` // client -> target
BytesOut int64 `json:"bytesOut"` // target -> client
}
// LogEntry is one access-log line.
type LogEntry struct {
ID int64 `json:"id,omitempty"` // assigned by hammerhead
Time time.Time `json:"time"`
Mapping string `json:"mapping"`
OrgID string `json:"orgId,omitempty"` // filled in by hammerhead
Remote string `json:"remote"`
Method string `json:"method,omitempty"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status,omitempty"`
User string `json:"user,omitempty"`
Result string `json:"result,omitempty"` // allowed, denied, auth-required, no-target
BytesIn int64 `json:"bytesIn"`
BytesOut int64 `json:"bytesOut"`
Millis int64 `json:"ms"`
}
// Container is a Docker container seen by an agent.
type Container struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Ports []ContainerPort `json:"ports"`
Networks map[string]string `json:"networks"` // network name -> container IP
}
type ContainerPort struct {
Private int `json:"private"`
Public int `json:"public,omitempty"` // 0 = not published
Type string `json:"type"`
}
// EdgeReport is what blacktip posts back.
type EdgeReport struct {
IPs []string `json:"ips"`
PublicKey string `json:"publicKey"` // the edge's WireGuard public key
Status map[string]MappingStatus `json:"status"`
Stats map[string]Stat `json:"stats"` // deltas since last report
Containers []Container `json:"containers"`
MailChecks map[string]MailCheck `json:"mailChecks"` // by mail-egress ID
}
// EdgeRef tells a site where to dial.
type EdgeRef struct {
ID string `json:"id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"` // host:port (UDP) to send WireGuard packets to
PublicKey string `json:"publicKey"`
IP string `json:"ip"` // the edge's address inside the tunnel
}
// SiteConfig is what bamboo long-polls from hammerhead.
type SiteConfig struct {
Version int64 `json:"version"`
SiteID string `json:"siteId"`
TunnelIP string `json:"tunnelIp"` // this site's WireGuard address
Edges []EdgeRef `json:"edges"`
MailEgress []MailSpec `json:"mailEgress"`
}
// SiteReport is what bamboo posts back.
type SiteReport struct {
PublicKey string `json:"publicKey"` // the agent's WireGuard public key
MailEgress map[string]string `json:"mailEgress"` // egress ID -> "" (listening) or an error
Connected []string `json:"connected"`
Containers []Container `json:"containers"`
}
// HostMatches reports whether host matches an exact name or a "*.example.com" pattern.
func HostMatches(pattern, host string) bool {
if pattern == host {
return true
}
return len(pattern) > 2 && pattern[:2] == "*." && len(host) > len(pattern)-1 && host[len(host)-len(pattern)+1:] == pattern[1:]
}
+107
View File
@@ -0,0 +1,107 @@
// Package netguard decides which network destinations a server-side feature
// may be pointed at. Anything that makes outbound connections on a user's behalf
// (webhooks, mail delivery) must refuse internal addresses by default, or it can
// be used to probe the machine's own network and cloud metadata services.
package netguard
import (
"context"
"errors"
"net"
"net/http"
"strconv"
"time"
)
var internal = func() []*net.IPNet {
var out []*net.IPNet
for _, c := range []string{"0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", "64:ff9b::/96", "2001:db8::/32"} {
_, n, _ := net.ParseCIDR(c)
out = append(out, n)
}
return out
}()
// Blocked reports whether ip is private, loopback, link-local, multicast,
// unspecified, or in a range reserved for carrier-grade NAT (which includes
// the WireGuard tunnel network) and similar.
func Blocked(ip net.IP) bool {
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return true
}
for _, n := range internal {
if n.Contains(ip) {
return true
}
}
return false
}
// ErrBlocked is returned when every address a name resolves to is internal.
var ErrBlocked = errors.New("not allowed: that address is on an internal network")
// DialContext resolves the host itself, checks every address, and connects to
// the one it checked, so a name that changes between the check and the
// connection (DNS rebinding) can't slip through.
func DialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
var ips []net.IP
if ip := net.ParseIP(host); ip != nil {
ips = []net.IP{ip}
} else {
res, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, r := range res {
ips = append(ips, r.IP)
}
}
var last error = errors.New("no addresses")
blocked := false
d := net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips {
if !allowPrivate && Blocked(ip) {
blocked = true
continue
}
c, err := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return c, nil
}
last = err
}
if blocked && !allowPrivate {
return nil, ErrBlocked
}
return nil, last
}
}
// Client is an HTTP client for user-supplied URLs: guarded dialing, a short
// timeout, and no following of redirects (a redirect could point somewhere internal).
func Client(allowPrivate bool) *http.Client {
return &http.Client{
Timeout: 12 * time.Second,
Transport: &http.Transport{DialContext: DialContext(allowPrivate), TLSHandshakeTimeout: 10 * time.Second, DisableKeepAlives: true},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
// PortOf is a small helper for URLs without an explicit port.
func PortOf(scheme, port string) string {
if port != "" {
return port
}
if scheme == "https" {
return strconv.Itoa(443)
}
return strconv.Itoa(80)
}
+105
View File
@@ -0,0 +1,105 @@
// Package tunnel holds what blacktip and bamboo agree on inside the WireGuard
// tunnel: the target header that starts each proxied connection, and a helper
// that pipes two connections together.
package tunnel
import (
"bufio"
"encoding/json"
"io"
"sync"
)
// Ports inside the WireGuard network where a site agent accepts proxied
// connections. TCP streams start with a Header line; UDP datagrams are
// prefixed as described in blacktip's udpTunnel.
const (
ProxyTCPPort = 9111
ProxyUDPPort = 9112
MailTCPPort = 9113 // on the edge: a site's SMTP relay hands it mail to deliver
)
// MailMsg is one request from a site's SMTP relay to the edge's mail port. The
// stream carries one mail transaction: "rcpt" (the first one also carries the
// sender and makes the edge connect to the recipient's server), more "rcpt",
// then "data" and "body". The edge answers each with a MailReply line.
type MailMsg struct {
Op string `json:"op"` // rcpt, data, body, rset, quit
Egress string `json:"egress,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Len int `json:"len,omitempty"` // body: that many bytes follow this line (the message, unstuffed)
}
type MailReply struct {
Code int `json:"code"`
Text string `json:"text"`
}
// MaxMessage is the largest message the relay accepts.
const MaxMessage = 50 << 20
// ReadLine reads up to '\n' one byte at a time, so nothing after it is consumed.
func ReadLine(r io.Reader) (string, error) {
var out []byte
one := make([]byte, 1)
for len(out) < 4096 {
if _, err := io.ReadFull(r, one); err != nil {
return "", err
}
if one[0] == '\n' {
return string(out), nil
}
out = append(out, one[0])
}
return "", io.ErrShortBuffer
}
type Header struct {
Proto string `json:"proto"` // "tcp" or "udp"
Host string `json:"host"`
Port int `json:"port"`
}
func WriteHeader(w io.Writer, h Header) error {
b, err := json.Marshal(h)
if err != nil {
return err
}
_, err = w.Write(append(b, '\n'))
return err
}
func ReadHeader(r *bufio.Reader) (Header, error) {
var h Header
line, err := r.ReadBytes('\n')
if err != nil {
return h, err
}
return h, json.Unmarshal(line, &h)
}
type closeWriter interface{ CloseWrite() error }
// Proxy copies both directions between a and b and closes both when done.
// aR and bR are the readers for each side (they may be buffered wrappers).
// It returns the bytes moved a->b and b->a.
func Proxy(a io.ReadWriteCloser, aR io.Reader, b io.ReadWriteCloser, bR io.Reader) (aToB, bToA int64) {
var wg sync.WaitGroup
cp := func(dst io.ReadWriteCloser, src io.Reader, n *int64) {
defer wg.Done()
*n, _ = io.Copy(dst, src)
if cw, ok := dst.(closeWriter); ok {
cw.CloseWrite()
} else {
dst.Close()
}
}
wg.Add(2)
go cp(b, aR, &aToB)
go cp(a, bR, &bToA)
wg.Wait()
a.Close()
b.Close()
return
}
+220
View File
@@ -0,0 +1,220 @@
// Package wg wraps wireguard-go with a userspace network stack (gVisor netstack),
// the same approach Newt uses: no root, no kernel interface, works on Windows.
//
// A Device has one tunnel address. Connections made with DialContext and
// listeners made with ListenTCP/ListenUDP live entirely inside the tunnel.
package wg
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"net/netip"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
)
const (
// MTU is conservative so the tunnel works over typical consumer links.
MTU = 1280
// Keepalive keeps NAT mappings open for peers behind NAT.
Keepalive = 25
)
// GenerateKey returns a new base64-encoded private key.
func GenerateKey() (string, error) {
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(k.Bytes()), nil
}
// PublicKey derives the base64 public key from a base64 private key.
func PublicKey(priv string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(priv)
if err != nil || len(raw) != 32 {
return "", errors.New("invalid WireGuard private key")
}
k, err := ecdh.X25519().NewPrivateKey(raw)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil
}
func toHex(b64 string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(raw) != 32 {
return "", errors.New("invalid WireGuard key")
}
return hex.EncodeToString(raw), nil
}
// Peer is one remote end of the tunnel.
type Peer struct {
PublicKey string // base64
AllowedIP string // the peer's tunnel address, e.g. 100.89.0.7
Endpoint string // host:port, or "" when the peer connects to us
}
func (p Peer) sig() string { return p.PublicKey + "|" + p.AllowedIP + "|" + p.Endpoint }
type Device struct {
dev *device.Device
net *netstack.Net
ip netip.Addr
mu sync.Mutex
peers map[string]Peer // by public key
}
// New brings up a tunnel endpoint at addr. listenPort 0 picks a random UDP port.
func New(privateKey string, addr netip.Addr, listenPort int) (*Device, error) {
privHex, err := toHex(privateKey)
if err != nil {
return nil, err
}
tdev, tnet, err := netstack.CreateNetTUN([]netip.Addr{addr}, nil, MTU)
if err != nil {
return nil, err
}
dev := device.NewDevice(tdev, conn.NewDefaultBind(), device.NewLogger(device.LogLevelError, "wireguard: "))
cfg := "private_key=" + privHex + "\n"
if listenPort > 0 {
cfg += "listen_port=" + strconv.Itoa(listenPort) + "\n"
}
if err := dev.IpcSet(cfg); err != nil {
dev.Close()
return nil, fmt.Errorf("configure wireguard: %w", err)
}
if err := dev.Up(); err != nil {
dev.Close()
return nil, err
}
return &Device{dev: dev, net: tnet, ip: addr, peers: map[string]Peer{}}, nil
}
func (d *Device) Addr() netip.Addr { return d.ip }
func (d *Device) Close() { d.dev.Close() }
// resolve turns host:port into the ip:port form wireguard-go requires.
func resolve(endpoint string) (string, error) {
if endpoint == "" {
return "", nil
}
a, err := net.ResolveUDPAddr("udp", endpoint)
if err != nil {
return "", err
}
return a.String(), nil
}
// SetPeers makes the device's peer list match want. Peers that are unchanged
// keep their sessions, so adding one site never interrupts the others.
// Peers whose endpoint can't be resolved are skipped (and returned in err).
func (d *Device) SetPeers(want []Peer) error {
d.mu.Lock()
defer d.mu.Unlock()
var cfg strings.Builder
var bad []string
next := map[string]Peer{}
for _, p := range want {
ep, err := resolve(p.Endpoint)
if err != nil {
bad = append(bad, fmt.Sprintf("%s: %v", p.Endpoint, err))
if old, ok := d.peers[p.PublicKey]; ok {
next[p.PublicKey] = old // keep what works
}
continue
}
p.Endpoint = ep
next[p.PublicKey] = p
}
for key := range d.peers {
if _, keep := next[key]; !keep {
h, _ := toHex(key)
cfg.WriteString("public_key=" + h + "\nremove=true\n")
}
}
keys := make([]string, 0, len(next))
for k := range next {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
p := next[key]
if old, ok := d.peers[key]; ok && old.sig() == p.sig() {
continue
}
h, err := toHex(p.PublicKey)
if err != nil {
continue
}
cfg.WriteString("public_key=" + h + "\nreplace_allowed_ips=true\nallowed_ip=" + p.AllowedIP + "/32\n")
if p.Endpoint != "" {
cfg.WriteString("endpoint=" + p.Endpoint + "\npersistent_keepalive_interval=" + strconv.Itoa(Keepalive) + "\n")
}
}
if cfg.Len() > 0 {
if err := d.dev.IpcSet(cfg.String()); err != nil {
return err
}
}
d.peers = next
if len(bad) > 0 {
return errors.New(strings.Join(bad, "; "))
}
return nil
}
// Handshakes returns the time of the last completed handshake per peer public key.
func (d *Device) Handshakes() map[string]time.Time {
out := map[string]time.Time{}
state, err := d.dev.IpcGet()
if err != nil {
return out
}
var cur string
for _, line := range strings.Split(state, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "public_key":
raw, _ := hex.DecodeString(v)
cur = base64.StdEncoding.EncodeToString(raw)
case "last_handshake_time_sec":
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 && cur != "" {
out[cur] = time.Unix(n, 0)
}
}
}
return out
}
func (d *Device) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
return d.net.DialContext(ctx, network, addr)
}
func (d *Device) ListenTCP(port int) (net.Listener, error) {
return d.net.ListenTCP(&net.TCPAddr{Port: port})
}
func (d *Device) ListenUDP(port int) (net.PacketConn, error) {
return d.net.ListenUDP(&net.UDPAddr{Port: port})
}
+124
View File
@@ -0,0 +1,124 @@
package wg
import (
"context"
"io"
"net"
"net/netip"
"strconv"
"testing"
"time"
)
func freeUDP(t *testing.T) int {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
return pc.LocalAddr().(*net.UDPAddr).Port
}
func TestTunnelCarriesTCPAndUDP(t *testing.T) {
ka, _ := GenerateKey()
kb, _ := GenerateKey()
pa, _ := PublicKey(ka)
pb, _ := PublicKey(kb)
portA := freeUDP(t)
a, err := New(ka, netip.MustParseAddr("100.89.0.1"), portA) // the "edge": has a fixed port
if err != nil {
t.Fatal(err)
}
defer a.Close()
b, err := New(kb, netip.MustParseAddr("100.89.0.2"), 0) // the "site": behind NAT, dials out
if err != nil {
t.Fatal(err)
}
defer b.Close()
if err := a.SetPeers([]Peer{{PublicKey: pb, AllowedIP: "100.89.0.2"}}); err != nil {
t.Fatal(err)
}
if err := b.SetPeers([]Peer{{PublicKey: pa, AllowedIP: "100.89.0.1", Endpoint: net.JoinHostPort("127.0.0.1", strconv.Itoa(portA))}}); err != nil {
t.Fatal(err)
}
// TCP: site listens inside the tunnel, edge dials it
ln, err := b.ListenTCP(9111)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() { defer c.Close(); io.Copy(c, c) }()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
c, err := a.DialContext(ctx, "tcp", "100.89.0.2:9111")
if err != nil {
t.Fatalf("dial through tunnel: %v", err)
}
defer c.Close()
c.Write([]byte("hello"))
buf := make([]byte, 5)
c.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(c, buf); err != nil || string(buf) != "hello" {
t.Fatalf("echo: %q %v", buf, err)
}
// UDP both ways
pc, err := b.ListenUDP(9112)
if err != nil {
t.Fatal(err)
}
defer pc.Close()
go func() {
p := make([]byte, 2048)
for {
n, from, err := pc.ReadFrom(p)
if err != nil {
return
}
pc.WriteTo(append([]byte("re:"), p[:n]...), from)
}
}()
u, err := a.DialContext(ctx, "udp", "100.89.0.2:9112")
if err != nil {
t.Fatal(err)
}
defer u.Close()
u.Write([]byte("ping"))
u.SetReadDeadline(time.Now().Add(5 * time.Second))
rb := make([]byte, 64)
n, err := u.Read(rb)
if err != nil || string(rb[:n]) != "re:ping" {
t.Fatalf("udp: %q %v", rb[:n], err)
}
// the site shows a recent handshake with the edge
if hs := b.Handshakes(); time.Since(hs[pa]) > time.Minute {
t.Errorf("no handshake recorded: %v", hs)
}
// a second peer can be added and removed without disturbing the first
kc, _ := GenerateKey()
pcKey, _ := PublicKey(kc)
if err := a.SetPeers([]Peer{{PublicKey: pb, AllowedIP: "100.89.0.2"}, {PublicKey: pcKey, AllowedIP: "100.89.0.3"}}); err != nil {
t.Fatal(err)
}
c.Write([]byte("again"))
buf2 := make([]byte, 5)
c.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(c, buf2); err != nil || string(buf2) != "again" {
t.Fatalf("existing connection broke when a peer was added: %q %v", buf2, err)
}
if err := a.SetPeers([]Peer{{PublicKey: pb, AllowedIP: "100.89.0.2"}}); err != nil {
t.Fatal(err)
}
}