初版功能完成
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 }
|
||||
Reference in New Issue
Block a user