初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+22
View File
@@ -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)
}
+92
View File
@@ -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{}
}
+83
View File
@@ -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")
}
})
}
}
+84
View File
@@ -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...))
},
}
}
+20
View File
@@ -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
}
+27
View File
@@ -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")
}
}
+121
View File
@@ -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()
}
+96
View File
@@ -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 }
+134
View File
@@ -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)
}
}
+189
View File
@@ -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 }
+45
View File
@@ -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
}
+85
View File
@@ -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")
}
}
+68
View File
@@ -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
}
+302
View File
@@ -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
}