WireGuard server with an embedded admin console
Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// Package wg abstracts the WireGuard data plane behind a small interface so the
|
||||
// rest of WGX does not care whether peers live in the kernel module, in a
|
||||
// userspace wireguard-go process, or in an in-memory mock used by tests and
|
||||
// UI development.
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Key is a 32-byte WireGuard key (public, private or preshared).
|
||||
type Key [32]byte
|
||||
|
||||
// PeerState is one peer as the data plane currently sees it.
|
||||
type PeerState struct {
|
||||
PublicKey Key
|
||||
Endpoint *net.UDPAddr
|
||||
LastHandshake time.Time // zero when the peer has never completed a handshake
|
||||
ReceiveBytes int64
|
||||
TransmitBytes int64
|
||||
AllowedIPs []netip.Prefix
|
||||
PersistentKeepalive time.Duration
|
||||
}
|
||||
|
||||
// PeerConfig is what WGX wants a peer to look like on the interface.
|
||||
type PeerConfig struct {
|
||||
PublicKey Key
|
||||
PresharedKey *Key
|
||||
AllowedIPs []netip.Prefix
|
||||
PersistentKeepalive time.Duration
|
||||
}
|
||||
|
||||
// DeviceConfig is the interface-level configuration.
|
||||
type DeviceConfig struct {
|
||||
PrivateKey Key
|
||||
ListenPort int
|
||||
// FirewallMark is applied to every packet the interface sends; zero means
|
||||
// none. Left at zero by WGX, but exposed for completeness.
|
||||
FirewallMark int
|
||||
}
|
||||
|
||||
// DeviceState is a snapshot of the interface.
|
||||
type DeviceState struct {
|
||||
Name string
|
||||
PublicKey Key
|
||||
ListenPort int
|
||||
Peers []PeerState
|
||||
}
|
||||
|
||||
// Backend is the data plane WGX drives.
|
||||
type Backend interface {
|
||||
// Kind names the implementation: "kernel", "userspace" or "mock".
|
||||
Kind() string
|
||||
// Up creates the interface (if needed), applies the device configuration
|
||||
// and brings the link up with the given addresses and MTU.
|
||||
Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error
|
||||
// Down tears the interface down and releases every resource Up acquired.
|
||||
Down(ctx context.Context) error
|
||||
// Device returns the current state of the interface and all of its peers.
|
||||
Device(ctx context.Context) (*DeviceState, error)
|
||||
// SetPeer adds or replaces a peer; AllowedIPs replace what was there.
|
||||
SetPeer(ctx context.Context, p PeerConfig) error
|
||||
// RemovePeer removes a peer. Removing a peer that is absent is not an error.
|
||||
RemovePeer(ctx context.Context, pub Key) error
|
||||
// ReplacePeers makes the interface's peer set exactly the given list.
|
||||
ReplacePeers(ctx context.Context, peers []PeerConfig) error
|
||||
// SetMTU changes the interface MTU without disturbing peers.
|
||||
SetMTU(ctx context.Context, mtu int) error
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package wg
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
// GeneratePrivateKey returns a fresh Curve25519 private key, clamped the way
|
||||
// WireGuard expects.
|
||||
func GeneratePrivateKey() (Key, error) {
|
||||
var k Key
|
||||
if _, err := rand.Read(k[:]); err != nil {
|
||||
return Key{}, fmt.Errorf("generate private key: %w", err)
|
||||
}
|
||||
k[0] &= 248
|
||||
k[31] &= 127
|
||||
k[31] |= 64
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// GeneratePresharedKey returns 32 random bytes for use as a preshared key.
|
||||
func GeneratePresharedKey() (Key, error) {
|
||||
var k Key
|
||||
if _, err := rand.Read(k[:]); err != nil {
|
||||
return Key{}, fmt.Errorf("generate preshared key: %w", err)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// PublicKey derives the public key of a private key.
|
||||
func (k Key) PublicKey() Key {
|
||||
var pub Key
|
||||
priv := k
|
||||
curve25519.ScalarBaseMult((*[32]byte)(&pub), (*[32]byte)(&priv))
|
||||
return pub
|
||||
}
|
||||
|
||||
// String renders the key the way wg(8) does: standard base64.
|
||||
func (k Key) String() string { return base64.StdEncoding.EncodeToString(k[:]) }
|
||||
|
||||
// IsZero reports whether the key is all zeros.
|
||||
func (k Key) IsZero() bool { return k == Key{} }
|
||||
|
||||
// ParseKey parses a base64 key as produced by wg genkey / wg pubkey.
|
||||
func ParseKey(s string) (Key, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return Key{}, errors.New("key is not valid base64")
|
||||
}
|
||||
if len(b) != 32 {
|
||||
return Key{}, errors.New("key must decode to 32 bytes")
|
||||
}
|
||||
var k Key
|
||||
copy(k[:], b)
|
||||
return k, nil
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//go:build linux
|
||||
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// linuxBackend drives a real WireGuard interface. In kernel mode the link is a
|
||||
// native `wireguard` netlink link and every packet is handled by the module;
|
||||
// in userspace mode a wireguard-go process owns a TUN device with the same
|
||||
// name and WGX talks to it over its UAPI socket. Both are configured through
|
||||
// wgctrl, which picks the transport on its own.
|
||||
type linuxBackend struct {
|
||||
name string
|
||||
userspace bool
|
||||
client *wgctrl.Client
|
||||
proc *exec.Cmd
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// KernelAvailable reports whether the running kernel can create a WireGuard
|
||||
// link. It tries to add and immediately delete a probe interface rather than
|
||||
// trusting /sys/module, because a module that is loadable but not yet loaded
|
||||
// is only discovered by asking for it.
|
||||
func KernelAvailable() bool {
|
||||
const probe = "wgxprobe0"
|
||||
link := &netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: probe}}
|
||||
if err := netlink.LinkAdd(link); err != nil {
|
||||
return false
|
||||
}
|
||||
_ = netlink.LinkDel(link)
|
||||
return true
|
||||
}
|
||||
|
||||
// NewKernel returns a backend that uses the kernel module.
|
||||
func NewKernel(name string, log *slog.Logger) (Backend, error) {
|
||||
return newLinux(name, false, log)
|
||||
}
|
||||
|
||||
// NewUserspace returns a backend that runs wireguard-go for the data plane.
|
||||
func NewUserspace(name string, log *slog.Logger) (Backend, error) {
|
||||
if _, err := exec.LookPath("wireguard-go"); err != nil {
|
||||
return nil, errors.New("wireguard-go is not installed and the kernel has no WireGuard support")
|
||||
}
|
||||
return newLinux(name, true, log)
|
||||
}
|
||||
|
||||
func newLinux(name string, userspace bool, log *slog.Logger) (Backend, error) {
|
||||
c, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open wgctrl: %w", err)
|
||||
}
|
||||
return &linuxBackend{name: name, userspace: userspace, client: c, log: log}, nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Kind() string {
|
||||
if b.userspace {
|
||||
return "userspace"
|
||||
}
|
||||
return "kernel"
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error {
|
||||
// A previous run that died without Down leaves the link behind. Start
|
||||
// clean rather than inheriting peers and addresses nobody remembers.
|
||||
if err := b.deleteLink(); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.userspace {
|
||||
if err := b.startUserspace(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: b.name}}); err != nil {
|
||||
return fmt.Errorf("create %s: %w (is the container running with NET_ADMIN?)", b.name, err)
|
||||
}
|
||||
}
|
||||
link, err := b.link()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priv := wgtypes.Key(cfg.PrivateKey)
|
||||
port := cfg.ListenPort
|
||||
wcfg := wgtypes.Config{PrivateKey: &priv, ListenPort: &port, ReplacePeers: true}
|
||||
if cfg.FirewallMark != 0 {
|
||||
fw := cfg.FirewallMark
|
||||
wcfg.FirewallMark = &fw
|
||||
}
|
||||
if err := b.client.ConfigureDevice(b.name, wcfg); err != nil {
|
||||
return fmt.Errorf("configure %s: %w", b.name, err)
|
||||
}
|
||||
for _, p := range addrs {
|
||||
// Not prefixToIPNet: that masks the host bits, and an interface
|
||||
// address must keep them (10.8.0.1/24, not 10.8.0.0/24).
|
||||
ipn := addrToIPNet(p)
|
||||
if err := netlink.AddrAdd(link, &netlink.Addr{IPNet: &ipn}); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("add address %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
if mtu > 0 {
|
||||
if err := netlink.LinkSetMTU(link, mtu); err != nil {
|
||||
return fmt.Errorf("set mtu %d: %w", mtu, err)
|
||||
}
|
||||
}
|
||||
if err := netlink.LinkSetUp(link); err != nil {
|
||||
return fmt.Errorf("bring up %s: %w", b.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) startUserspace(ctx context.Context) error {
|
||||
_ = os.MkdirAll("/var/run/wireguard", 0o700)
|
||||
cmd := exec.Command("wireguard-go", "-f", b.name)
|
||||
cmd.Env = append(os.Environ(), "WG_PROCESS_FOREGROUND=1", "LOG_LEVEL=error")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start wireguard-go: %w", err)
|
||||
}
|
||||
b.proc = cmd
|
||||
sock := filepath.Join("/var/run/wireguard", b.name+".sock")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(sock); err == nil {
|
||||
if _, err := netlink.LinkByName(b.name); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return errors.New("wireguard-go did not create its UAPI socket in time")
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Down(ctx context.Context) error {
|
||||
var errs []error
|
||||
if err := b.deleteLink(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if b.proc != nil && b.proc.Process != nil {
|
||||
_ = b.proc.Process.Kill()
|
||||
_ = b.proc.Wait()
|
||||
b.proc = nil
|
||||
}
|
||||
if err := b.client.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (b *linuxBackend) deleteLink() error {
|
||||
link, err := netlink.LinkByName(b.name)
|
||||
if err != nil {
|
||||
var nf netlink.LinkNotFoundError
|
||||
if errors.As(err, &nf) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("look up %s: %w", b.name, err)
|
||||
}
|
||||
if err := netlink.LinkDel(link); err != nil {
|
||||
return fmt.Errorf("delete stale %s: %w", b.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) link() (netlink.Link, error) {
|
||||
link, err := netlink.LinkByName(b.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("look up %s: %w", b.name, err)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Device(ctx context.Context) (*DeviceState, error) {
|
||||
d, err := b.client.Device(b.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", b.name, err)
|
||||
}
|
||||
st := &DeviceState{Name: d.Name, PublicKey: Key(d.PublicKey), ListenPort: d.ListenPort}
|
||||
st.Peers = make([]PeerState, 0, len(d.Peers))
|
||||
for _, p := range d.Peers {
|
||||
ps := PeerState{
|
||||
PublicKey: Key(p.PublicKey),
|
||||
Endpoint: p.Endpoint,
|
||||
LastHandshake: p.LastHandshakeTime,
|
||||
ReceiveBytes: p.ReceiveBytes,
|
||||
TransmitBytes: p.TransmitBytes,
|
||||
PersistentKeepalive: p.PersistentKeepaliveInterval,
|
||||
}
|
||||
for _, a := range p.AllowedIPs {
|
||||
if pfx, ok := ipNetToPrefix(a); ok {
|
||||
ps.AllowedIPs = append(ps.AllowedIPs, pfx)
|
||||
}
|
||||
}
|
||||
st.Peers = append(st.Peers, ps)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) SetPeer(ctx context.Context, p PeerConfig) error {
|
||||
return b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{toPeerConfig(p)}})
|
||||
}
|
||||
|
||||
func (b *linuxBackend) RemovePeer(ctx context.Context, pub Key) error {
|
||||
err := b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{{PublicKey: wgtypes.Key(pub), Remove: true}}})
|
||||
if err != nil && strings.Contains(err.Error(), "no such") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *linuxBackend) ReplacePeers(ctx context.Context, peers []PeerConfig) error {
|
||||
cfg := wgtypes.Config{ReplacePeers: true}
|
||||
for _, p := range peers {
|
||||
cfg.Peers = append(cfg.Peers, toPeerConfig(p))
|
||||
}
|
||||
return b.client.ConfigureDevice(b.name, cfg)
|
||||
}
|
||||
|
||||
func (b *linuxBackend) SetMTU(ctx context.Context, mtu int) error {
|
||||
link, err := b.link()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return netlink.LinkSetMTU(link, mtu)
|
||||
}
|
||||
|
||||
func toPeerConfig(p PeerConfig) wgtypes.PeerConfig {
|
||||
pc := wgtypes.PeerConfig{PublicKey: wgtypes.Key(p.PublicKey), ReplaceAllowedIPs: true}
|
||||
if p.PresharedKey != nil {
|
||||
psk := wgtypes.Key(*p.PresharedKey)
|
||||
pc.PresharedKey = &psk
|
||||
}
|
||||
if p.PersistentKeepalive > 0 {
|
||||
ka := p.PersistentKeepalive
|
||||
pc.PersistentKeepaliveInterval = &ka
|
||||
}
|
||||
for _, a := range p.AllowedIPs {
|
||||
pc.AllowedIPs = append(pc.AllowedIPs, prefixToIPNet(a))
|
||||
}
|
||||
return pc
|
||||
}
|
||||
|
||||
// prefixToIPNet converts a route prefix; host bits are cleared.
|
||||
func prefixToIPNet(p netip.Prefix) net.IPNet {
|
||||
return addrToIPNet(p.Masked())
|
||||
}
|
||||
|
||||
// addrToIPNet converts an interface address with its prefix length, keeping
|
||||
// the host bits.
|
||||
func addrToIPNet(p netip.Prefix) net.IPNet {
|
||||
ip := p.Addr()
|
||||
if ip.Is4() {
|
||||
a := ip.As4()
|
||||
return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 32)}
|
||||
}
|
||||
a := ip.As16()
|
||||
return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 128)}
|
||||
}
|
||||
|
||||
func ipNetToPrefix(n net.IPNet) (netip.Prefix, bool) {
|
||||
addr, ok := netip.AddrFromSlice(n.IP)
|
||||
if !ok {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
ones, _ := n.Mask.Size()
|
||||
return netip.PrefixFrom(addr, ones), true
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build linux
|
||||
|
||||
package wg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIPNetConversions(t *testing.T) {
|
||||
// An interface address keeps its host bits; a route prefix loses them.
|
||||
// Getting this wrong once put 10.8.0.0/24 on the interface, and the
|
||||
// server answered nothing on 10.8.0.1.
|
||||
addr := addrToIPNet(netip.MustParsePrefix("10.8.0.1/24"))
|
||||
if addr.IP.String() != "10.8.0.1" {
|
||||
t.Fatalf("address lost host bits: %s", addr.IP)
|
||||
}
|
||||
if ones, _ := addr.Mask.Size(); ones != 24 {
|
||||
t.Fatalf("mask %d", ones)
|
||||
}
|
||||
route := prefixToIPNet(netip.MustParsePrefix("10.8.0.7/24"))
|
||||
if route.IP.String() != "10.8.0.0" {
|
||||
t.Fatalf("route kept host bits: %s", route.IP)
|
||||
}
|
||||
v6 := addrToIPNet(netip.MustParsePrefix("fd42::1/64"))
|
||||
if v6.IP.String() != "fd42::1" || len(v6.IP) != 16 {
|
||||
t.Fatalf("v6 %s", v6.IP)
|
||||
}
|
||||
back, ok := ipNetToPrefix(route)
|
||||
if !ok || back.String() != "10.8.0.0/24" {
|
||||
t.Fatalf("round trip %v %v", back, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeys(t *testing.T) {
|
||||
priv, err := GeneratePrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if priv[0]&7 != 0 || priv[31]&128 != 0 || priv[31]&64 == 0 {
|
||||
t.Fatal("private key not clamped")
|
||||
}
|
||||
pub := priv.PublicKey()
|
||||
parsed, err := ParseKey(pub.String())
|
||||
if err != nil || parsed != pub {
|
||||
t.Fatal("public key does not round-trip through base64")
|
||||
}
|
||||
if _, err := ParseKey("not base64!"); err == nil {
|
||||
t.Fatal("bad key accepted")
|
||||
}
|
||||
if _, err := ParseKey("AAAA"); err == nil {
|
||||
t.Fatal("short key accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock is an in-memory data plane. It needs no privileges, so it is what the
|
||||
// tests use and what `WGX_BACKEND=mock` gives a developer working on the UI.
|
||||
// With Simulate on, peers randomly handshake, move traffic and go quiet so
|
||||
// the dashboard has something to show.
|
||||
type Mock struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
cfg DeviceConfig
|
||||
peers map[Key]*mockPeer
|
||||
up bool
|
||||
Simulate bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
type mockPeer struct {
|
||||
cfg PeerConfig
|
||||
endpoint *net.UDPAddr
|
||||
handshake time.Time
|
||||
rx, tx int64
|
||||
active bool
|
||||
}
|
||||
|
||||
// NewMock returns an empty mock backend for the named interface.
|
||||
func NewMock(name string, simulate bool) *Mock {
|
||||
return &Mock{name: name, peers: map[Key]*mockPeer{}, Simulate: simulate}
|
||||
}
|
||||
|
||||
func (m *Mock) Kind() string { return "mock" }
|
||||
|
||||
func (m *Mock) Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg = cfg
|
||||
m.up = true
|
||||
if m.Simulate && m.stop == nil {
|
||||
m.stop = make(chan struct{})
|
||||
go m.simulate(m.stop)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) Down(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.up = false
|
||||
if m.stop != nil {
|
||||
close(m.stop)
|
||||
m.stop = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) Device(ctx context.Context) (*DeviceState, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
st := &DeviceState{Name: m.name, PublicKey: m.cfg.PrivateKey.PublicKey(), ListenPort: m.cfg.ListenPort}
|
||||
keys := make([]Key, 0, len(m.peers))
|
||||
for k := range m.peers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() })
|
||||
for _, k := range keys {
|
||||
p := m.peers[k]
|
||||
st.Peers = append(st.Peers, PeerState{
|
||||
PublicKey: k,
|
||||
Endpoint: p.endpoint,
|
||||
LastHandshake: p.handshake,
|
||||
ReceiveBytes: p.rx,
|
||||
TransmitBytes: p.tx,
|
||||
AllowedIPs: append([]netip.Prefix(nil), p.cfg.AllowedIPs...),
|
||||
PersistentKeepalive: p.cfg.PersistentKeepalive,
|
||||
})
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (m *Mock) SetPeer(ctx context.Context, p PeerConfig) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if existing, ok := m.peers[p.PublicKey]; ok {
|
||||
existing.cfg = p
|
||||
return nil
|
||||
}
|
||||
// Half of new peers start out busy so a fresh mock install has
|
||||
// something moving on the dashboard straight away.
|
||||
m.peers[p.PublicKey] = &mockPeer{cfg: p, active: m.Simulate && rand.IntN(2) == 0}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) RemovePeer(ctx context.Context, pub Key) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.peers, pub)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) ReplacePeers(ctx context.Context, peers []PeerConfig) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
next := map[Key]*mockPeer{}
|
||||
for _, p := range peers {
|
||||
if existing, ok := m.peers[p.PublicKey]; ok {
|
||||
existing.cfg = p
|
||||
next[p.PublicKey] = existing
|
||||
} else {
|
||||
next[p.PublicKey] = &mockPeer{cfg: p}
|
||||
}
|
||||
}
|
||||
m.peers = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) SetMTU(ctx context.Context, mtu int) error { return nil }
|
||||
|
||||
// Touch fakes a handshake and some traffic for a peer. Tests use it to make
|
||||
// a peer look connected without waiting on the simulator.
|
||||
func (m *Mock) Touch(pub Key, rx, tx int64, endpoint string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.peers[pub]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.handshake = time.Now()
|
||||
p.rx += rx
|
||||
p.tx += tx
|
||||
if endpoint != "" {
|
||||
if ap, err := netip.ParseAddrPort(endpoint); err == nil {
|
||||
p.endpoint = net.UDPAddrFromAddrPort(ap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mock) simulate(stop chan struct{}) {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
m.mu.Lock()
|
||||
for _, p := range m.peers {
|
||||
// Peers flip between active and idle a few times an hour.
|
||||
if rand.IntN(60) == 0 {
|
||||
p.active = !p.active
|
||||
}
|
||||
if p.active {
|
||||
if p.endpoint == nil {
|
||||
p.endpoint = &net.UDPAddr{IP: net.IPv4(203, 0, 113, byte(1+rand.IntN(250))), Port: 30000 + rand.IntN(30000)}
|
||||
}
|
||||
if time.Since(p.handshake) > time.Duration(90+rand.IntN(40))*time.Second {
|
||||
p.handshake = time.Now()
|
||||
}
|
||||
p.rx += int64(rand.IntN(400_000))
|
||||
p.tx += int64(rand.IntN(3_000_000))
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !linux
|
||||
|
||||
package wg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var errLinuxOnly = errors.New("real WireGuard interfaces are only supported on Linux; use WGX_BACKEND=mock for development")
|
||||
|
||||
// KernelAvailable is always false off Linux.
|
||||
func KernelAvailable() bool { return false }
|
||||
|
||||
// NewKernel is unavailable off Linux.
|
||||
func NewKernel(name string, log *slog.Logger) (Backend, error) { return nil, errLinuxOnly }
|
||||
|
||||
// NewUserspace is unavailable off Linux.
|
||||
func NewUserspace(name string, log *slog.Logger) (Backend, error) { return nil, errLinuxOnly }
|
||||
Reference in New Issue
Block a user