初版功能完成
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
// Adapter supplies the one RemLink TUN device created by platform/windows.
|
||||
type Adapter interface {
|
||||
Device() tun.Device
|
||||
}
|
||||
|
||||
// NewFromAdapter transfers ownership of the Adapter's live TUN session to an
|
||||
// embedded wireguard-go Device.
|
||||
func NewFromAdapter(adapter Adapter, logger *slog.Logger) (*Device, error) {
|
||||
if adapter == nil {
|
||||
return nil, errors.New("RemLink adapter must not be nil")
|
||||
}
|
||||
return NewDevice(adapter.Device(), logger)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const keySize = 32
|
||||
|
||||
// Key is one raw 32-byte WireGuard private or public key.
|
||||
type Key [keySize]byte
|
||||
|
||||
// Config is the one-Server-peer wireguard-go configuration used by v1 Nodes.
|
||||
type Config struct {
|
||||
PrivateKey Key
|
||||
ServerPublicKey Key
|
||||
ServerEndpoint string
|
||||
OverlayAllowedIPs []netip.Prefix
|
||||
ListenPort uint16
|
||||
PersistentKeepalive time.Duration
|
||||
}
|
||||
|
||||
func (c Config) uapi() (string, error) {
|
||||
if err := c.validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
fmt.Fprintf(&builder, "private_key=%s\n", hex.EncodeToString(c.PrivateKey[:]))
|
||||
fmt.Fprintf(&builder, "listen_port=%d\n", c.ListenPort)
|
||||
builder.WriteString("replace_peers=true\n")
|
||||
fmt.Fprintf(&builder, "public_key=%s\n", hex.EncodeToString(c.ServerPublicKey[:]))
|
||||
fmt.Fprintf(&builder, "endpoint=%s\n", c.ServerEndpoint)
|
||||
fmt.Fprintf(&builder, "persistent_keepalive_interval=%d\n", int(c.PersistentKeepalive/time.Second))
|
||||
builder.WriteString("replace_allowed_ips=true\n")
|
||||
for _, prefix := range c.OverlayAllowedIPs {
|
||||
fmt.Fprintf(&builder, "allowed_ip=%s\n", prefix)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if zeroKey(c.PrivateKey) {
|
||||
return errors.New("WireGuard private key must not be zero")
|
||||
}
|
||||
if zeroKey(c.ServerPublicKey) {
|
||||
return errors.New("Server WireGuard public key must not be zero")
|
||||
}
|
||||
if c.PrivateKey == c.ServerPublicKey {
|
||||
return errors.New("Node private key and Server public key must differ")
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(c.ServerEndpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Server WireGuard endpoint must be host:port: %w", err)
|
||||
}
|
||||
if host == "" || portText == "" {
|
||||
return errors.New("Server WireGuard endpoint must include a host and port")
|
||||
}
|
||||
port, err := strconv.ParseUint(portText, 10, 16)
|
||||
if err != nil || port == 0 {
|
||||
return errors.New("Server WireGuard endpoint port must be between 1 and 65535")
|
||||
}
|
||||
if len(c.OverlayAllowedIPs) != 1 {
|
||||
return errors.New("v1 requires exactly one Overlay AllowedIP and one Server peer")
|
||||
}
|
||||
for _, prefix := range c.OverlayAllowedIPs {
|
||||
if !prefix.IsValid() || !prefix.Addr().Is4() || prefix.Bits() == 0 {
|
||||
return fmt.Errorf("Overlay AllowedIP must be IPv4: %s", prefix)
|
||||
}
|
||||
if prefix != prefix.Masked() {
|
||||
return fmt.Errorf("Overlay AllowedIP must use its network address: %s", prefix)
|
||||
}
|
||||
}
|
||||
if c.PersistentKeepalive < 0 || c.PersistentKeepalive > 65535*time.Second {
|
||||
return errors.New("persistent keepalive must be between 0 and 65535 seconds")
|
||||
}
|
||||
if c.PersistentKeepalive%time.Second != 0 {
|
||||
return errors.New("persistent keepalive must be a whole number of seconds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zeroKey(key Key) bool {
|
||||
return key == Key{}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigUAPIGolden(t *testing.T) {
|
||||
t.Parallel()
|
||||
privateKey := Key{1, 2, 3}
|
||||
serverKey := Key{4, 5, 6}
|
||||
config := Config{
|
||||
PrivateKey: privateKey,
|
||||
ServerPublicKey: serverKey,
|
||||
ServerEndpoint: "203.0.113.10:51820",
|
||||
OverlayAllowedIPs: []netip.Prefix{netip.MustParsePrefix("10.88.0.0/16")},
|
||||
PersistentKeepalive: 25 * time.Second,
|
||||
}
|
||||
got, err := config.uapi()
|
||||
if err != nil {
|
||||
t.Fatalf("uapi() error = %v", err)
|
||||
}
|
||||
checks := []string{
|
||||
"private_key=0102030000000000000000000000000000000000000000000000000000000000\n",
|
||||
"listen_port=0\n",
|
||||
"replace_peers=true\n",
|
||||
"public_key=0405060000000000000000000000000000000000000000000000000000000000\n",
|
||||
"endpoint=203.0.113.10:51820\n",
|
||||
"persistent_keepalive_interval=25\n",
|
||||
"replace_allowed_ips=true\n",
|
||||
"allowed_ip=10.88.0.0/16\n",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(got, check) {
|
||||
t.Fatalf("uapi() missing %q in:\n%s", check, got)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(got, "\n\n") {
|
||||
t.Fatalf("uapi() must terminate with a blank line: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsNonV1Values(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := Config{
|
||||
PrivateKey: Key{1},
|
||||
ServerPublicKey: Key{2},
|
||||
ServerEndpoint: "203.0.113.10:51820",
|
||||
OverlayAllowedIPs: []netip.Prefix{netip.MustParsePrefix("10.88.0.0/16")},
|
||||
PersistentKeepalive: 25 * time.Second,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
}{
|
||||
{name: "zero private key", mutate: func(c *Config) { c.PrivateKey = Key{} }},
|
||||
{name: "zero public key", mutate: func(c *Config) { c.ServerPublicKey = Key{} }},
|
||||
{name: "same keys", mutate: func(c *Config) { c.ServerPublicKey = c.PrivateKey }},
|
||||
{name: "endpoint", mutate: func(c *Config) { c.ServerEndpoint = "203.0.113.10" }},
|
||||
{name: "endpoint port", mutate: func(c *Config) { c.ServerEndpoint = "203.0.113.10:not-a-port" }},
|
||||
{name: "no AllowedIPs", mutate: func(c *Config) { c.OverlayAllowedIPs = nil }},
|
||||
{name: "multiple AllowedIPs", mutate: func(c *Config) {
|
||||
c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("10.88.0.0/16"), netip.MustParsePrefix("192.168.13.0/24")}
|
||||
}},
|
||||
{name: "IPv6", mutate: func(c *Config) { c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("fd00::/64")} }},
|
||||
{name: "Exit Node", mutate: func(c *Config) { c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")} }},
|
||||
{name: "unmasked", mutate: func(c *Config) { c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("10.88.0.1/16")} }},
|
||||
{name: "fractional keepalive", mutate: func(c *Config) { c.PersistentKeepalive = 1500 * time.Millisecond }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := valid
|
||||
test.mutate(&config)
|
||||
if _, err := config.uapi(); err == nil {
|
||||
t.Fatal("uapi() unexpectedly accepted invalid config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"golang.zx2c4.com/wireguard/conn"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
// Device owns the MuxTun, wireguard-go engine, and transport bind.
|
||||
type Device struct {
|
||||
tun *MuxTun
|
||||
wireguard *device.Device
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewDevice transfers ownership of base to a new embedded wireguard-go Device.
|
||||
func NewDevice(base tun.Device, logger *slog.Logger) (*Device, error) {
|
||||
if base == nil {
|
||||
return nil, errors.New("base TUN device must not be nil")
|
||||
}
|
||||
mux := NewMuxTun(base)
|
||||
wireguard := device.NewDevice(mux, conn.NewDefaultBind(), wireGuardLogger(logger))
|
||||
return &Device{tun: mux, wireguard: wireguard}, nil
|
||||
}
|
||||
|
||||
// Configure atomically replaces the single Server peer through wireguard-go UAPI.
|
||||
func (d *Device) Configure(config Config) error {
|
||||
uapi, err := config.uapi()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.wireguard.IpcSet(uapi); err != nil {
|
||||
return fmt.Errorf("configure embedded wireguard-go: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Up starts the embedded WireGuard device after configuration.
|
||||
func (d *Device) Up() error {
|
||||
if err := d.wireguard.Up(); err != nil {
|
||||
return fmt.Errorf("bring embedded wireguard-go up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UAPIState returns wireguard-go's current UAPI state for diagnostics.
|
||||
func (d *Device) UAPIState() (string, error) {
|
||||
return d.wireguard.IpcGet()
|
||||
}
|
||||
|
||||
// MuxTun returns the single packet boundary used by Session transport.
|
||||
func (d *Device) MuxTun() *MuxTun { return d.tun }
|
||||
|
||||
// Close is idempotent and stops wireguard-go, MuxTun, and the base Wintun.
|
||||
func (d *Device) Close() {
|
||||
if d == nil || d.wireguard == nil {
|
||||
return
|
||||
}
|
||||
d.closeOnce.Do(func() {
|
||||
d.wireguard.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func wireGuardLogger(logger *slog.Logger) *device.Logger {
|
||||
if logger == nil {
|
||||
return &device.Logger{
|
||||
Verbosef: device.DiscardLogf,
|
||||
Errorf: device.DiscardLogf,
|
||||
}
|
||||
}
|
||||
return &device.Logger{
|
||||
Verbosef: func(format string, args ...any) {
|
||||
logger.Debug(fmt.Sprintf(format, args...))
|
||||
},
|
||||
Errorf: func(format string, args ...any) {
|
||||
logger.Error(fmt.Sprintf(format, args...))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ParseKeyBase64 parses the standard WireGuard 32-byte base64 key format.
|
||||
func ParseKeyBase64(value string) (Key, error) {
|
||||
var key Key
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return key, fmt.Errorf("decode WireGuard key: %w", err)
|
||||
}
|
||||
if len(decoded) != keySize {
|
||||
return key, fmt.Errorf("WireGuard key decoded length is %d, want %d", len(decoded), keySize)
|
||||
}
|
||||
copy(key[:], decoded)
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseKeyBase64(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := make([]byte, keySize)
|
||||
for index := range raw {
|
||||
raw[index] = byte(index + 1)
|
||||
}
|
||||
key, err := ParseKeyBase64(base64.StdEncoding.EncodeToString(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseKeyBase64() error = %v", err)
|
||||
}
|
||||
if key[0] != 1 || key[31] != 32 {
|
||||
t.Fatalf("ParseKeyBase64() returned unexpected bytes: first=%d last=%d", key[0], key[31])
|
||||
}
|
||||
if _, err := ParseKeyBase64("not-base64"); err == nil {
|
||||
t.Fatal("ParseKeyBase64() accepted invalid base64")
|
||||
}
|
||||
if _, err := ParseKeyBase64(base64.StdEncoding.EncodeToString([]byte{1, 2, 3})); err == nil {
|
||||
t.Fatal("ParseKeyBase64() accepted a short key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package clientwg embeds wireguard-go around the single RemLink Wintun.
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
var _ tun.Device = (*MuxTun)(nil)
|
||||
|
||||
// MuxTun is the deliberately thin Phase 1 wrapper around the real Wintun.
|
||||
// Later phases add Read-side CIDR classification without changing this surface.
|
||||
type MuxTun struct {
|
||||
base tun.Device
|
||||
router atomic.Pointer[PacketMux]
|
||||
writeMu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewMuxTun wraps a live TUN device without changing its semantics.
|
||||
func NewMuxTun(base tun.Device) *MuxTun {
|
||||
return &MuxTun{base: base}
|
||||
}
|
||||
|
||||
func (m *MuxTun) File() *os.File {
|
||||
return m.base.File()
|
||||
}
|
||||
|
||||
func (m *MuxTun) Read(bufs [][]byte, sizes []int, offset int) (int, error) {
|
||||
router := m.router.Load()
|
||||
if router == nil {
|
||||
return m.base.Read(bufs, sizes, offset)
|
||||
}
|
||||
if len(bufs) == 0 || len(sizes) < len(bufs) || offset < 0 {
|
||||
return 0, errors.New("invalid MuxTun read buffers")
|
||||
}
|
||||
for {
|
||||
count, readErr := m.base.Read(bufs, sizes, offset)
|
||||
if count < 0 || count > len(bufs) {
|
||||
return 0, errors.New("base TUN returned invalid batch count")
|
||||
}
|
||||
overlayCount := 0
|
||||
for index := 0; index < count; index++ {
|
||||
size := sizes[index]
|
||||
if size < 0 || offset+size > len(bufs[index]) {
|
||||
return 0, errors.New("base TUN returned invalid packet size")
|
||||
}
|
||||
packet := bufs[index][offset : offset+size]
|
||||
class, sink := router.route(packet)
|
||||
switch class {
|
||||
case PacketOverlay:
|
||||
if overlayCount != index {
|
||||
if offset+size > len(bufs[overlayCount]) {
|
||||
return 0, errors.New("destination batch buffer is too small")
|
||||
}
|
||||
copy(bufs[overlayCount][offset:offset+size], packet)
|
||||
}
|
||||
sizes[overlayCount] = size
|
||||
overlayCount++
|
||||
case PacketRemote:
|
||||
if sink != nil {
|
||||
owned := append([]byte(nil), packet...)
|
||||
if !sink.Enqueue(owned) {
|
||||
router.recordDrop(DropEvent{Reason: DropRemoteQueueFull})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlayCount > 0 || readErr != nil {
|
||||
return overlayCount, readErr
|
||||
}
|
||||
// A batch containing only Remote/dropped packets is consumed here. Read
|
||||
// again instead of returning 0,nil to wireguard-go and spinning it.
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MuxTun) Write(bufs [][]byte, offset int) (int, error) {
|
||||
m.writeMu.Lock()
|
||||
defer m.writeMu.Unlock()
|
||||
return m.base.Write(bufs, offset)
|
||||
}
|
||||
|
||||
// SetPacketMux enables or replaces Engineer outbound classification. A nil
|
||||
// value restores Phase 1 passthrough behavior.
|
||||
func (m *MuxTun) SetPacketMux(router *PacketMux) { m.router.Store(router) }
|
||||
|
||||
// InjectInbound serializes Site Session replies with wireguard-go writes.
|
||||
func (m *MuxTun) InjectInbound(packet []byte) error {
|
||||
m.writeMu.Lock()
|
||||
defer m.writeMu.Unlock()
|
||||
_, err := m.base.Write([][]byte{packet}, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *MuxTun) MTU() (int, error) {
|
||||
return m.base.MTU()
|
||||
}
|
||||
|
||||
func (m *MuxTun) Name() (string, error) {
|
||||
return m.base.Name()
|
||||
}
|
||||
|
||||
func (m *MuxTun) Events() <-chan tun.Event {
|
||||
return m.base.Events()
|
||||
}
|
||||
|
||||
func (m *MuxTun) Close() error {
|
||||
m.closeOnce.Do(func() {
|
||||
m.closeErr = m.base.Close()
|
||||
})
|
||||
return m.closeErr
|
||||
}
|
||||
|
||||
func (m *MuxTun) BatchSize() int {
|
||||
return m.base.BatchSize()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
func TestMuxTunProxiesBaseSemanticsAndClosesOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := newFakeTUN()
|
||||
mux := NewMuxTun(base)
|
||||
if name, err := mux.Name(); err != nil || name != "RemLink" {
|
||||
t.Fatalf("Name() = (%q, %v)", name, err)
|
||||
}
|
||||
if mtu, err := mux.MTU(); err != nil || mtu != 1280 {
|
||||
t.Fatalf("MTU() = (%d, %v)", mtu, err)
|
||||
}
|
||||
if mux.BatchSize() != 1 {
|
||||
t.Fatalf("BatchSize() = %d, want 1", mux.BatchSize())
|
||||
}
|
||||
if mux.Events() != base.events {
|
||||
t.Fatal("Events() did not return the base channel")
|
||||
}
|
||||
|
||||
buffer := make([]byte, 32)
|
||||
sizes := make([]int, 1)
|
||||
n, err := mux.Read([][]byte{buffer}, sizes, 4)
|
||||
if err != nil || n != 1 || sizes[0] != 3 || string(buffer[4:7]) != "out" {
|
||||
t.Fatalf("Read() = n=%d sizes=%v data=%q err=%v", n, sizes, buffer[4:7], err)
|
||||
}
|
||||
n, err = mux.Write([][]byte{[]byte("xxxxin")}, 4)
|
||||
if err != nil || n != 1 || string(base.written) != "in" {
|
||||
t.Fatalf("Write() = n=%d written=%q err=%v", n, base.written, err)
|
||||
}
|
||||
|
||||
if err := mux.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if err := mux.Close(); err != nil {
|
||||
t.Fatalf("second Close() error = %v", err)
|
||||
}
|
||||
if base.closeCalls != 1 {
|
||||
t.Fatalf("base Close() calls = %d, want 1", base.closeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuxTunPreservesBaseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := errors.New("read failed")
|
||||
base := newFakeTUN()
|
||||
base.readErr = want
|
||||
_, err := NewMuxTun(base).Read([][]byte{make([]byte, 8)}, make([]int, 1), 0)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("Read() error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTUN struct {
|
||||
events chan tun.Event
|
||||
written []byte
|
||||
readErr error
|
||||
closeCalls int
|
||||
}
|
||||
|
||||
func newFakeTUN() *fakeTUN {
|
||||
return &fakeTUN{events: make(chan tun.Event, 1)}
|
||||
}
|
||||
|
||||
func (f *fakeTUN) File() *os.File { return nil }
|
||||
|
||||
func (f *fakeTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) {
|
||||
if f.readErr != nil {
|
||||
return 0, f.readErr
|
||||
}
|
||||
sizes[0] = copy(bufs[0][offset:], []byte("out"))
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeTUN) Write(bufs [][]byte, offset int) (int, error) {
|
||||
f.written = append(f.written[:0], bufs[0][offset:]...)
|
||||
return len(bufs), nil
|
||||
}
|
||||
|
||||
func (f *fakeTUN) MTU() (int, error) { return 1280, nil }
|
||||
func (f *fakeTUN) Name() (string, error) { return "RemLink", nil }
|
||||
func (f *fakeTUN) Events() <-chan tun.Event {
|
||||
return f.events
|
||||
}
|
||||
func (f *fakeTUN) Close() error {
|
||||
f.closeCalls++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeTUN) BatchSize() int { return 1 }
|
||||
@@ -0,0 +1,134 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// PacketClass is the only Engineer outbound routing decision.
|
||||
type PacketClass uint8
|
||||
|
||||
const (
|
||||
PacketDrop PacketClass = iota
|
||||
PacketOverlay
|
||||
PacketRemote
|
||||
)
|
||||
|
||||
type DropReason string
|
||||
|
||||
const (
|
||||
DropInvalidIPv4 DropReason = "INVALID_IPV4"
|
||||
DropUnmanagedDestination DropReason = "UNMANAGED_DESTINATION"
|
||||
DropRemoteQueueFull DropReason = "REMOTE_QUEUE_FULL"
|
||||
)
|
||||
|
||||
type DropEvent struct {
|
||||
Reason DropReason
|
||||
Destination netip.Addr
|
||||
}
|
||||
|
||||
type dropHandler struct{ callback func(DropEvent) }
|
||||
|
||||
// RemoteSink accepts an owned copy without performing network I/O in Read.
|
||||
type RemoteSink interface {
|
||||
Enqueue(packet []byte) bool
|
||||
}
|
||||
|
||||
type routeSnapshot struct {
|
||||
overlay netip.Prefix
|
||||
remote []netip.Prefix
|
||||
sink RemoteSink
|
||||
}
|
||||
|
||||
// PacketMux classifies raw IPv4 packets using an atomically replaced snapshot.
|
||||
type PacketMux struct {
|
||||
routes atomic.Pointer[routeSnapshot]
|
||||
onDrop atomic.Pointer[dropHandler]
|
||||
overlayPackets atomic.Uint64
|
||||
remotePackets atomic.Uint64
|
||||
remoteBytes atomic.Uint64
|
||||
droppedPackets atomic.Uint64
|
||||
}
|
||||
|
||||
func NewPacketMux(overlay netip.Prefix, remote []netip.Prefix, sink RemoteSink) *PacketMux {
|
||||
mux := &PacketMux{}
|
||||
mux.SetRoutes(overlay, remote, sink)
|
||||
return mux
|
||||
}
|
||||
|
||||
// SetRoutes replaces the Active Session CIDRs as one consistent snapshot.
|
||||
func (m *PacketMux) SetRoutes(overlay netip.Prefix, remote []netip.Prefix, sink RemoteSink) {
|
||||
copyOfRemote := append([]netip.Prefix(nil), remote...)
|
||||
m.routes.Store(&routeSnapshot{overlay: overlay.Masked(), remote: copyOfRemote, sink: sink})
|
||||
}
|
||||
|
||||
// SetDropHandler installs an optional metadata-only callback for rate-limited
|
||||
// logging. The callback never receives packet bytes.
|
||||
func (m *PacketMux) SetDropHandler(callback func(DropEvent)) {
|
||||
if callback == nil {
|
||||
m.onDrop.Store(nil)
|
||||
return
|
||||
}
|
||||
m.onDrop.Store(&dropHandler{callback: callback})
|
||||
}
|
||||
|
||||
// Classify validates enough of IPv4 to safely read Destination Address.
|
||||
func (m *PacketMux) Classify(packet []byte) PacketClass {
|
||||
routes := m.routes.Load()
|
||||
class, _, _ := classifySnapshot(routes, packet)
|
||||
return class
|
||||
}
|
||||
|
||||
func classifySnapshot(routes *routeSnapshot, packet []byte) (PacketClass, RemoteSink, DropEvent) {
|
||||
if routes == nil || len(packet) < 20 || packet[0]>>4 != 4 {
|
||||
return PacketDrop, nil, DropEvent{Reason: DropInvalidIPv4}
|
||||
}
|
||||
headerLength := int(packet[0]&0x0F) * 4
|
||||
totalLength := int(packet[2])<<8 | int(packet[3])
|
||||
if headerLength < 20 || headerLength > len(packet) || totalLength < headerLength || totalLength != len(packet) {
|
||||
return PacketDrop, nil, DropEvent{Reason: DropInvalidIPv4}
|
||||
}
|
||||
destination := netip.AddrFrom4([4]byte{packet[16], packet[17], packet[18], packet[19]})
|
||||
if routes.overlay.IsValid() && routes.overlay.Contains(destination) {
|
||||
return PacketOverlay, nil, DropEvent{}
|
||||
}
|
||||
for _, prefix := range routes.remote {
|
||||
if prefix.Contains(destination) {
|
||||
return PacketRemote, routes.sink, DropEvent{}
|
||||
}
|
||||
}
|
||||
return PacketDrop, nil, DropEvent{Reason: DropUnmanagedDestination, Destination: destination}
|
||||
}
|
||||
|
||||
// Counters returns cumulative classification outcomes.
|
||||
func (m *PacketMux) Counters() (overlay, remote, dropped uint64) {
|
||||
return m.overlayPackets.Load(), m.remotePackets.Load(), m.droppedPackets.Load()
|
||||
}
|
||||
|
||||
// RemoteCounters is the Engineer-view Upload source of truth: bytes and
|
||||
// packets are counted when PacketMux intercepts them, before queueing or UDP
|
||||
// host I/O, exactly as required by the Session statistics contract.
|
||||
func (m *PacketMux) RemoteCounters() (bytes, packets uint64) {
|
||||
return m.remoteBytes.Load(), m.remotePackets.Load()
|
||||
}
|
||||
|
||||
func (m *PacketMux) route(packet []byte) (PacketClass, RemoteSink) {
|
||||
class, sink, drop := classifySnapshot(m.routes.Load(), packet)
|
||||
switch class {
|
||||
case PacketOverlay:
|
||||
m.overlayPackets.Add(1)
|
||||
case PacketRemote:
|
||||
m.remotePackets.Add(1)
|
||||
m.remoteBytes.Add(uint64(len(packet)))
|
||||
default:
|
||||
m.recordDrop(drop)
|
||||
}
|
||||
return class, sink
|
||||
}
|
||||
|
||||
func (m *PacketMux) recordDrop(event DropEvent) {
|
||||
m.droppedPackets.Add(1)
|
||||
if handler := m.onDrop.Load(); handler != nil {
|
||||
handler.callback(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package clientwg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
type collectingSink struct{ packets [][]byte }
|
||||
|
||||
func (s *collectingSink) Enqueue(packet []byte) bool {
|
||||
s.packets = append(s.packets, packet)
|
||||
return true
|
||||
}
|
||||
|
||||
type rejectingSink struct{}
|
||||
|
||||
func (rejectingSink) Enqueue([]byte) bool { return false }
|
||||
|
||||
func TestPacketMuxClassifiesIPv4Destination(t *testing.T) {
|
||||
mux := NewPacketMux(
|
||||
netip.MustParsePrefix("10.88.0.0/16"),
|
||||
[]netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")},
|
||||
nil,
|
||||
)
|
||||
for _, test := range []struct {
|
||||
packet []byte
|
||||
want PacketClass
|
||||
}{
|
||||
{ipv4Packet("10.88.0.3"), PacketOverlay},
|
||||
{ipv4Packet("192.168.13.10"), PacketRemote},
|
||||
{ipv4Packet("8.8.8.8"), PacketDrop},
|
||||
{[]byte{0x60, 0, 0, 20}, PacketDrop},
|
||||
{[]byte{0x45, 0, 0, 40}, PacketDrop},
|
||||
} {
|
||||
if got := mux.Classify(test.packet); got != test.want {
|
||||
t.Errorf("Classify(%v) = %v, want %v", test.packet, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuxTunConsumesRemoteOnlyBatchUntilOverlay(t *testing.T) {
|
||||
base := &sequenceTUN{
|
||||
events: make(chan tun.Event),
|
||||
batches: [][][]byte{
|
||||
{ipv4Packet("192.168.13.10")},
|
||||
{ipv4Packet("10.88.0.3")},
|
||||
},
|
||||
}
|
||||
sink := &collectingSink{}
|
||||
router := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"),
|
||||
[]netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, sink)
|
||||
mux := NewMuxTun(base)
|
||||
mux.SetPacketMux(router)
|
||||
buffer := make([]byte, 128)
|
||||
sizes := make([]int, 1)
|
||||
count, err := mux.Read([][]byte{buffer}, sizes, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || base.reads != 2 || len(sink.packets) != 1 {
|
||||
t.Fatalf("count=%d reads=%d remote=%d", count, base.reads, len(sink.packets))
|
||||
}
|
||||
if destination(buffer[4:4+sizes[0]]) != netip.MustParseAddr("10.88.0.3") {
|
||||
t.Fatalf("returned packet destination = %s", destination(buffer[4:4+sizes[0]]))
|
||||
}
|
||||
if destination(sink.packets[0]) != netip.MustParseAddr("192.168.13.10") {
|
||||
t.Fatalf("queued packet destination = %s", destination(sink.packets[0]))
|
||||
}
|
||||
base.batches[0][0][16] = 1
|
||||
if destination(sink.packets[0]) != netip.MustParseAddr("192.168.13.10") {
|
||||
t.Fatal("RemoteSink packet aliases the base TUN buffer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuxTunCompactsMixedBatch(t *testing.T) {
|
||||
base := &sequenceTUN{
|
||||
events: make(chan tun.Event),
|
||||
batches: [][][]byte{{
|
||||
ipv4Packet("192.168.13.10"), ipv4Packet("10.88.0.4"), ipv4Packet("8.8.8.8"), ipv4Packet("10.88.0.5"),
|
||||
}},
|
||||
}
|
||||
sink := &collectingSink{}
|
||||
router := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"),
|
||||
[]netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, sink)
|
||||
mux := NewMuxTun(base)
|
||||
mux.SetPacketMux(router)
|
||||
bufs := [][]byte{make([]byte, 64), make([]byte, 64), make([]byte, 64), make([]byte, 64)}
|
||||
sizes := make([]int, len(bufs))
|
||||
count, err := mux.Read(bufs, sizes, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 || destination(bufs[0][:sizes[0]]).String() != "10.88.0.4" || destination(bufs[1][:sizes[1]]).String() != "10.88.0.5" {
|
||||
t.Fatalf("compacted count=%d destinations=%s,%s", count, destination(bufs[0][:sizes[0]]), destination(bufs[1][:sizes[1]]))
|
||||
}
|
||||
overlay, remote, dropped := router.Counters()
|
||||
if overlay != 2 || remote != 1 || dropped != 1 {
|
||||
t.Fatalf("counters overlay=%d remote=%d dropped=%d", overlay, remote, dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketMuxDropCallbackContainsMetadataOnly(t *testing.T) {
|
||||
mux := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"), nil, nil)
|
||||
var events []DropEvent
|
||||
mux.SetDropHandler(func(event DropEvent) { events = append(events, event) })
|
||||
mux.route(ipv4Packet("8.8.8.8"))
|
||||
mux.route([]byte{0x60})
|
||||
if len(events) != 2 || events[0].Reason != DropUnmanagedDestination || events[0].Destination.String() != "8.8.8.8" || events[1].Reason != DropInvalidIPv4 {
|
||||
t.Fatalf("drop events = %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketMuxCountsInterceptedUploadAndReportsQueueDrop(t *testing.T) {
|
||||
base := &sequenceTUN{
|
||||
events: make(chan tun.Event),
|
||||
batches: [][][]byte{{ipv4Packet("192.168.13.10")}, {ipv4Packet("10.88.0.3")}},
|
||||
}
|
||||
router := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"),
|
||||
[]netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, rejectingSink{})
|
||||
var drops []DropEvent
|
||||
router.SetDropHandler(func(event DropEvent) { drops = append(drops, event) })
|
||||
mux := NewMuxTun(base)
|
||||
mux.SetPacketMux(router)
|
||||
buffer := make([]byte, 64)
|
||||
sizes := make([]int, 1)
|
||||
if _, err := mux.Read([][]byte{buffer}, sizes, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bytes, packets := router.RemoteCounters()
|
||||
_, remote, dropped := router.Counters()
|
||||
if bytes != 20 || packets != 1 || remote != 1 || dropped != 1 {
|
||||
t.Fatalf("Upload/drop counters bytes=%d packets=%d remote=%d dropped=%d", bytes, packets, remote, dropped)
|
||||
}
|
||||
if len(drops) != 1 || drops[0].Reason != DropRemoteQueueFull {
|
||||
t.Fatalf("queue drop events = %+v", drops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketMuxRejectsTrailingBytesBeyondIPv4TotalLength(t *testing.T) {
|
||||
packet := append(ipv4Packet("192.168.13.10"), 0)
|
||||
mux := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"),
|
||||
[]netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, nil)
|
||||
if got := mux.Classify(packet); got != PacketDrop {
|
||||
t.Fatalf("Classify packet with trailing bytes = %v, want PacketDrop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func ipv4Packet(destinationText string) []byte {
|
||||
packet := make([]byte, 20)
|
||||
packet[0] = 0x45
|
||||
packet[2] = 0
|
||||
packet[3] = 20
|
||||
packet[12] = 10
|
||||
packet[13] = 88
|
||||
packet[14] = 0
|
||||
packet[15] = 2
|
||||
destination := netip.MustParseAddr(destinationText).As4()
|
||||
copy(packet[16:20], destination[:])
|
||||
return packet
|
||||
}
|
||||
|
||||
func destination(packet []byte) netip.Addr {
|
||||
return netip.AddrFrom4([4]byte{packet[16], packet[17], packet[18], packet[19]})
|
||||
}
|
||||
|
||||
type sequenceTUN struct {
|
||||
events chan tun.Event
|
||||
batches [][][]byte
|
||||
reads int
|
||||
}
|
||||
|
||||
func (t *sequenceTUN) File() *os.File { return nil }
|
||||
func (t *sequenceTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) {
|
||||
batch := t.batches[t.reads]
|
||||
t.reads++
|
||||
for index, packet := range batch {
|
||||
sizes[index] = copy(bufs[index][offset:], packet)
|
||||
}
|
||||
return len(batch), nil
|
||||
}
|
||||
func (t *sequenceTUN) Write(bufs [][]byte, offset int) (int, error) { return len(bufs), nil }
|
||||
func (t *sequenceTUN) MTU() (int, error) { return 1280, nil }
|
||||
func (t *sequenceTUN) Name() (string, error) { return "RemLink", nil }
|
||||
func (t *sequenceTUN) Events() <-chan tun.Event { return t.events }
|
||||
func (t *sequenceTUN) Close() error { return nil }
|
||||
func (t *sequenceTUN) BatchSize() int { return 4 }
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package serverwg creates and configures the Linux kernel WireGuard hub.
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
const DefaultInterfaceName = "wg0"
|
||||
|
||||
// Config contains kernel interface and host-forwarding settings.
|
||||
type Config struct {
|
||||
InterfaceName string
|
||||
Address netip.Prefix
|
||||
ListenPort int
|
||||
PrivateKeyPath string
|
||||
EnableForwarding bool
|
||||
IPTablesPath string
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if c.InterfaceName == "" {
|
||||
return errors.New("WireGuard interface name must not be empty")
|
||||
}
|
||||
if !c.Address.Addr().Is4() || c.Address.Bits() > 30 {
|
||||
return errors.New("WireGuard address must be an IPv4 interface prefix")
|
||||
}
|
||||
if c.Address.Addr() == c.Address.Masked().Addr() {
|
||||
return errors.New("WireGuard address must be a usable host address, not its network address")
|
||||
}
|
||||
if c.ListenPort < 1 || c.ListenPort > 65535 {
|
||||
return fmt.Errorf("WireGuard listen port %d is outside 1..65535", c.ListenPort)
|
||||
}
|
||||
if c.PrivateKeyPath == "" {
|
||||
return errors.New("Server private key path must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Peer is one Node's cryptokey-routing entry.
|
||||
type Peer struct {
|
||||
PublicKey string
|
||||
Address netip.Addr
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
valid := Config{
|
||||
InterfaceName: "wg0", Address: netip.MustParsePrefix("10.88.0.1/16"),
|
||||
ListenPort: 51820, PrivateKeyPath: filepath.Join(t.TempDir(), "server.key"),
|
||||
}
|
||||
if err := valid.validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := valid
|
||||
invalid.Address = netip.MustParsePrefix("10.88.0.0/16")
|
||||
if err := invalid.validate(); err == nil {
|
||||
t.Fatal("network address accepted as Server address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreatePrivateKeyIsConcurrentAndAtomic(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "keys", "server.key")
|
||||
const workers = 8
|
||||
keys := make(chan string, workers)
|
||||
errorsSeen := make(chan error, workers)
|
||||
var group sync.WaitGroup
|
||||
for range workers {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
key, err := LoadOrCreatePrivateKey(path)
|
||||
if err != nil {
|
||||
errorsSeen <- err
|
||||
return
|
||||
}
|
||||
keys <- key.String()
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(keys)
|
||||
close(errorsSeen)
|
||||
for err := range errorsSeen {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := ""
|
||||
for key := range keys {
|
||||
if want == "" {
|
||||
want = key
|
||||
}
|
||||
if key != want {
|
||||
t.Fatalf("concurrent creators observed different keys %s and %s", want, key)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != want+"\n" {
|
||||
t.Fatalf("published key file = %q, %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreatePrivateKeyIsStable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "keys", "server.key")
|
||||
first, err := LoadOrCreatePrivateKey(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := LoadOrCreatePrivateKey(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("Server private key changed on reload")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != first.String()+"\n" {
|
||||
t.Fatal("private key file has unexpected contents")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// LoadOrCreatePrivateKey reads a Server key or atomically creates a 0600 file.
|
||||
func LoadOrCreatePrivateKey(path string) (wgtypes.Key, error) {
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
key, err := wgtypes.ParseKey(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("parse Server private key %q: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("restrict Server private key permissions: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return wgtypes.Key{}, fmt.Errorf("read Server private key %q: %w", path, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("create Server key directory: %w", err)
|
||||
}
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("generate Server WireGuard private key: %w", err)
|
||||
}
|
||||
directory := filepath.Dir(path)
|
||||
file, err := os.CreateTemp(directory, ".server-wg-*.tmp")
|
||||
if err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("create temporary Server private key: %w", err)
|
||||
}
|
||||
temporaryPath := file.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := file.Chmod(0o600); err != nil {
|
||||
file.Close()
|
||||
return wgtypes.Key{}, fmt.Errorf("restrict temporary Server private key: %w", err)
|
||||
}
|
||||
writeErr := func() error {
|
||||
if _, err := file.WriteString(key.String() + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}()
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("write Server private key: %w", writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("close Server private key: %w", closeErr)
|
||||
}
|
||||
// A hard link publishes the fully flushed inode without overwriting a key
|
||||
// concurrently created by another Server process. Temporary and final files
|
||||
// are guaranteed to be on the same data-directory filesystem.
|
||||
if err := os.Link(temporaryPath, path); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return LoadOrCreatePrivateKey(path)
|
||||
}
|
||||
return wgtypes.Key{}, fmt.Errorf("publish Server private key %q: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//go:build linux
|
||||
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// Manager owns RemLink's wg0 and its dynamic Node peers.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
config Config
|
||||
client *wgctrl.Client
|
||||
key wgtypes.Key
|
||||
}
|
||||
|
||||
// New creates/reuses the kernel WireGuard interface and applies hub settings.
|
||||
func New(ctx context.Context, config Config) (*Manager, error) {
|
||||
if err := config.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := LoadOrCreatePrivateKey(config.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link, err := ensureLink(config.InterfaceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := configureAddress(link, config.Address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open wgctrl: %w", err)
|
||||
}
|
||||
manager := &Manager{config: config, client: client, key: key}
|
||||
if err := client.ConfigureDevice(config.InterfaceName, wgtypes.Config{
|
||||
PrivateKey: &key, ListenPort: &config.ListenPort,
|
||||
}); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("configure kernel WireGuard interface %s: %w", config.InterfaceName, err)
|
||||
}
|
||||
if err := netlink.LinkSetUp(link); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("bring up %s: %w", config.InterfaceName, err)
|
||||
}
|
||||
if config.EnableForwarding {
|
||||
if err := enableIPv4Forwarding(); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureHubForwardRule(ctx, config); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// PublicKey is safe to return in Bootstrap responses.
|
||||
func (m *Manager) PublicKey() string { return m.key.PublicKey().String() }
|
||||
|
||||
// Close releases control sockets but deliberately leaves the kernel interface.
|
||||
func (m *Manager) Close() error { return m.client.Close() }
|
||||
|
||||
// EnsurePeer creates or replaces one Node's only AllowedIPs entry with /32.
|
||||
func (m *Manager) EnsurePeer(ctx context.Context, publicKey string, address netip.Addr) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
peer, err := peerConfig(Peer{PublicKey: publicKey, Address: address})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}}); err != nil {
|
||||
return fmt.Errorf("ensure WireGuard peer %s: %w", address, err)
|
||||
}
|
||||
// The kernel mutation has completed. A cancellation observed afterwards
|
||||
// must not be reported as failure, because callers may otherwise roll back
|
||||
// adjacent database state while leaving the peer applied.
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePeer revokes a Node's WireGuard public key.
|
||||
func (m *Manager) RemovePeer(ctx context.Context, publicKey string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := wgtypes.ParseKey(publicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard public key: %w", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{
|
||||
Peers: []wgtypes.PeerConfig{{PublicKey: key, Remove: true}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("remove WireGuard peer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastHandshake returns the kernel-observed handshake time for one peer.
|
||||
func (m *Manager) LastHandshake(ctx context.Context, publicKey string) (*time.Time, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := wgtypes.ParseKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse WireGuard public key: %w", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
device, err := m.client.Device(m.config.InterfaceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read WireGuard device %s: %w", m.config.InterfaceName, err)
|
||||
}
|
||||
for _, peer := range device.Peers {
|
||||
if peer.PublicKey == key {
|
||||
if peer.LastHandshakeTime.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
handshake := peer.LastHandshakeTime.UTC()
|
||||
return &handshake, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ReconcilePeers replaces kernel peers from the authoritative Node Registry.
|
||||
func (m *Manager) ReconcilePeers(ctx context.Context, peers []Peer) error {
|
||||
configs := make([]wgtypes.PeerConfig, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
config, err := peerConfig(peer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{
|
||||
ReplacePeers: true, Peers: configs,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("reconcile WireGuard peers: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconfigure atomically applies a new Server Overlay address/listen port and
|
||||
// complete peer set as part of Admin network migration.
|
||||
func (m *Manager) Reconfigure(ctx context.Context, address netip.Prefix, listenPort int, peers []Peer) error {
|
||||
next := m.config
|
||||
next.Address = address
|
||||
next.ListenPort = listenPort
|
||||
if err := next.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
configs := make([]wgtypes.PeerConfig, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
config, err := peerConfig(peer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
link, err := netlink.LinkByName(m.config.InterfaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up %s for reconfiguration: %w", m.config.InterfaceName, err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
oldAddress := m.config.Address
|
||||
if err := configureAddress(link, address); err != nil {
|
||||
_ = configureAddress(link, oldAddress)
|
||||
return err
|
||||
}
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{
|
||||
ListenPort: &listenPort, ReplacePeers: true, Peers: configs,
|
||||
}); err != nil {
|
||||
_ = configureAddress(link, oldAddress)
|
||||
return fmt.Errorf("reconfigure kernel WireGuard: %w", err)
|
||||
}
|
||||
m.config = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func peerConfig(peer Peer) (wgtypes.PeerConfig, error) {
|
||||
key, err := wgtypes.ParseKey(peer.PublicKey)
|
||||
if err != nil {
|
||||
return wgtypes.PeerConfig{}, fmt.Errorf("parse WireGuard public key: %w", err)
|
||||
}
|
||||
if !peer.Address.Is4() {
|
||||
return wgtypes.PeerConfig{}, errors.New("WireGuard peer address must be IPv4")
|
||||
}
|
||||
bits := peer.Address.As4()
|
||||
allowedIP := net.IPNet{IP: net.IPv4(bits[0], bits[1], bits[2], bits[3]), Mask: net.CIDRMask(32, 32)}
|
||||
return wgtypes.PeerConfig{
|
||||
PublicKey: key, ReplaceAllowedIPs: true, AllowedIPs: []net.IPNet{allowedIP},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureLink(name string) (netlink.Link, error) {
|
||||
link, err := netlink.LinkByName(name)
|
||||
if err == nil {
|
||||
if link.Type() != "wireguard" {
|
||||
return nil, fmt.Errorf("interface %s exists with type %s, want wireguard", name, link.Type())
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
if _, notFound := err.(netlink.LinkNotFoundError); !notFound {
|
||||
return nil, fmt.Errorf("look up interface %s: %w", name, err)
|
||||
}
|
||||
link = &netlink.GenericLink{LinkAttrs: netlink.LinkAttrs{Name: name}, LinkType: "wireguard"}
|
||||
if err := netlink.LinkAdd(link); err != nil {
|
||||
return nil, fmt.Errorf("create kernel WireGuard interface %s: %w", name, err)
|
||||
}
|
||||
return netlink.LinkByName(name)
|
||||
}
|
||||
|
||||
func configureAddress(link netlink.Link, desired netip.Prefix) error {
|
||||
addresses, err := netlink.AddrList(link, netlink.FAMILY_V4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list %s addresses: %w", link.Attrs().Name, err)
|
||||
}
|
||||
desiredText := desired.String()
|
||||
for index := range addresses {
|
||||
if addresses[index].IPNet.String() == desiredText {
|
||||
continue
|
||||
}
|
||||
if err := netlink.AddrDel(link, &addresses[index]); err != nil {
|
||||
return fmt.Errorf("remove stale %s address %s: %w", link.Attrs().Name, addresses[index].IPNet, err)
|
||||
}
|
||||
}
|
||||
address, err := netlink.ParseAddr(desiredText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert WireGuard address: %w", err)
|
||||
}
|
||||
if err := netlink.AddrReplace(link, address); err != nil {
|
||||
return fmt.Errorf("configure %s address %s: %w", link.Attrs().Name, desired, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enableIPv4Forwarding() error {
|
||||
const path = "/proc/sys/net/ipv4/ip_forward"
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read IPv4 forwarding state: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(value)) == "1" {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("1\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("enable IPv4 forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureHubForwardRule(ctx context.Context, config Config) error {
|
||||
path := config.IPTablesPath
|
||||
if path == "" {
|
||||
var err error
|
||||
path, err = exec.LookPath("iptables")
|
||||
if err != nil {
|
||||
return errors.New("iptables is required to allow wg0-to-wg0 forwarding")
|
||||
}
|
||||
}
|
||||
arguments := []string{"FORWARD", "-i", config.InterfaceName, "-o", config.InterfaceName, "-j", "ACCEPT"}
|
||||
check := exec.CommandContext(ctx, path, append([]string{"-C"}, arguments...)...)
|
||||
if err := check.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
insert := exec.CommandContext(ctx, path, append([]string{"-I"}, arguments...)...)
|
||||
output, err := insert.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("allow %s-to-%s forwarding: %w: %s", config.InterfaceName, config.InterfaceName, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build !linux
|
||||
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrLinuxRequired = errors.New("kernel WireGuard Server requires Linux")
|
||||
|
||||
// Manager is unavailable on non-Linux build targets.
|
||||
type Manager struct{}
|
||||
|
||||
func New(context.Context, Config) (*Manager, error) { return nil, ErrLinuxRequired }
|
||||
func (*Manager) PublicKey() string { return "" }
|
||||
func (*Manager) Close() error { return nil }
|
||||
func (*Manager) EnsurePeer(context.Context, string, netip.Addr) error {
|
||||
return ErrLinuxRequired
|
||||
}
|
||||
func (*Manager) RemovePeer(context.Context, string) error { return ErrLinuxRequired }
|
||||
func (*Manager) ReconcilePeers(context.Context, []Peer) error {
|
||||
return ErrLinuxRequired
|
||||
}
|
||||
func (*Manager) Reconfigure(context.Context, netip.Prefix, int, []Peer) error {
|
||||
return ErrLinuxRequired
|
||||
}
|
||||
func (*Manager) LastHandshake(context.Context, string) (*time.Time, error) {
|
||||
return nil, ErrLinuxRequired
|
||||
}
|
||||
Reference in New Issue
Block a user