diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3b13b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.db +*.db-wal +*.db-shm +master.key diff --git a/README.md b/README.md index 2fa9241..a93f3f2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ # Reef -A shared library for Hammerhead, Blacktip, and Bamboo \ No newline at end of file +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`. diff --git a/authtoken/authtoken.go b/authtoken/authtoken.go new file mode 100644 index 0000000..bf30497 --- /dev/null +++ b/authtoken/authtoken.go @@ -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 +} diff --git a/docker/docker.go b/docker/docker.go new file mode 100644 index 0000000..741ad2f --- /dev/null +++ b/docker/docker.go @@ -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 +} diff --git a/docker/docker_test.go b/docker/docker_test.go new file mode 100644 index 0000000..6f26b88 --- /dev/null +++ b/docker/docker_test.go @@ -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") + } +} diff --git a/docker/pipe_other.go b/docker/pipe_other.go new file mode 100644 index 0000000..4bffc58 --- /dev/null +++ b/docker/pipe_other.go @@ -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") + } +} diff --git a/docker/pipe_windows.go b/docker/pipe_windows.go new file mode 100644 index 0000000..29e8ac3 --- /dev/null +++ b/docker/pipe_windows.go @@ -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) + } +} diff --git a/dotenv/dotenv.go b/dotenv/dotenv.go new file mode 100644 index 0000000..a798ac3 --- /dev/null +++ b/dotenv/dotenv.go @@ -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 / --env= 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 +} diff --git a/dotenv/dotenv_test.go b/dotenv/dotenv_test.go new file mode 100644 index 0000000..ca75070 --- /dev/null +++ b/dotenv/dotenv_test.go @@ -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) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e145fa6 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9760b72 --- /dev/null +++ b/go.sum @@ -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= diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..a035f8a --- /dev/null +++ b/model/model.go @@ -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 ._domainkey.. +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:] +} diff --git a/netguard/netguard.go b/netguard/netguard.go new file mode 100644 index 0000000..52b0e53 --- /dev/null +++ b/netguard/netguard.go @@ -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) +} diff --git a/tunnel/tunnel.go b/tunnel/tunnel.go new file mode 100644 index 0000000..7081e17 --- /dev/null +++ b/tunnel/tunnel.go @@ -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 +} diff --git a/wg/wg.go b/wg/wg.go new file mode 100644 index 0000000..0831962 --- /dev/null +++ b/wg/wg.go @@ -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}) +} diff --git a/wg/wg_test.go b/wg/wg_test.go new file mode 100644 index 0000000..fc7d0eb --- /dev/null +++ b/wg/wg_test.go @@ -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) + } +}