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:
1 parent
c7c2ae8f8d
commit
5177093360
16 files changed
+1358
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
master.key
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
@@ -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:]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user