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:
jcoffey
2026-09-12 19:56:08 -07:00
commit 6c006e1d4d
72 changed files with 11675 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
package engine
import (
"errors"
"fmt"
"net/netip"
)
// allocate returns the lowest free host address in the subnet, skipping the
// network address, the server's own (first host) and, for IPv4, the
// broadcast address. `used` holds addresses already handed out.
func allocate(subnet netip.Prefix, used map[string]bool) (netip.Addr, error) {
subnet = subnet.Masked()
base := subnet.Addr()
server := base.Next()
var last netip.Addr
if base.Is4() {
// Broadcast: all host bits set.
a := base.As4()
hostBits := 32 - subnet.Bits()
var n uint32 = uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])
n |= (1 << hostBits) - 1
last = netip.AddrFrom4([4]byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)})
}
// Cap the scan: a /64 is not walked to the end, and nobody has 65k peers.
const maxScan = 65536
addr := server.Next()
for i := 0; i < maxScan && subnet.Contains(addr); i++ {
if base.Is4() && addr == last {
break
}
if !used[addr.String()] {
return addr, nil
}
addr = addr.Next()
}
return netip.Addr{}, errors.New("no free addresses left in " + subnet.String())
}
// checkAddress validates an operator-chosen address for a peer.
func checkAddress(subnet netip.Prefix, s string, used map[string]bool) (netip.Addr, error) {
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, fmt.Errorf("%q is not an IP address", s)
}
if !subnet.Contains(a) {
return netip.Addr{}, fmt.Errorf("%s is outside %s", a, subnet.Masked())
}
if a == subnet.Masked().Addr() || a == subnet.Masked().Addr().Next() {
return netip.Addr{}, fmt.Errorf("%s is reserved for the server", a)
}
if used[a.String()] {
return netip.Addr{}, fmt.Errorf("%s is already assigned", a)
}
return a, nil
}
+277
View File
@@ -0,0 +1,277 @@
package engine
import (
"context"
"sync"
"time"
"github.com/Coffey-Labs/WGX/internal/store"
)
// Live is what the data plane currently says about one peer, merged with
// the totals persisted across restarts.
type Live struct {
PeerID string `json:"id"`
Connected bool `json:"connected"`
Endpoint string `json:"endpoint,omitempty"`
LastHandshake time.Time `json:"lastHandshake,omitempty"`
Rx int64 `json:"rx"`
Tx int64 `json:"tx"`
RxRate float64 `json:"rxRate"`
TxRate float64 `json:"txRate"`
ConnectedSince time.Time `json:"connectedSince,omitempty"`
}
// Totals summarises the whole interface.
type Totals struct {
Peers int `json:"peers"`
Active int `json:"active"`
Connected int `json:"connected"`
Rx int64 `json:"rx"`
Tx int64 `json:"tx"`
RxRate float64 `json:"rxRate"`
TxRate float64 `json:"txRate"`
}
// Snapshot is the payload pushed to dashboards on every poll.
type Snapshot struct {
At time.Time `json:"at"`
Totals Totals `json:"totals"`
Peers map[string]Live `json:"peers"`
}
type counterMemo struct {
rx, tx int64 // raw device counters at the last poll
at time.Time
}
// collector polls the backend, turns raw counters into deltas and rates,
// and batches what needs writing.
type collector struct {
e *Engine
mu sync.Mutex
live map[string]*Live // by peer id
memo map[string]counterMemo // by public key
// base is the persisted total per peer at the moment it was loaded, so
// that live totals = base + everything seen since.
pendingBuckets map[string]map[int64]*[2]int64 // peer id -> bucket -> [rx, tx]
dirty map[string]bool
last Snapshot
}
func newCollector(e *Engine) *collector {
return &collector{e: e, live: map[string]*Live{}, memo: map[string]counterMemo{}, pendingBuckets: map[string]map[int64]*[2]int64{}, dirty: map[string]bool{}}
}
// Snapshot returns the last computed snapshot.
func (c *collector) Snapshot() Snapshot {
c.mu.Lock()
defer c.mu.Unlock()
return c.last
}
// LiveFor returns a copy of a peer's live state, if any.
func (c *collector) LiveFor(id string) (Live, bool) {
c.mu.Lock()
defer c.mu.Unlock()
l, ok := c.live[id]
if !ok {
return Live{}, false
}
return *l, true
}
func (c *collector) run(ctx context.Context) {
t := time.NewTicker(c.e.cfg.PollInterval)
defer t.Stop()
c.poll(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.poll(ctx)
}
}
}
// forget drops a peer's live state after it is deleted.
func (c *collector) forget(id, pubKey string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.live, id)
delete(c.memo, pubKey)
delete(c.pendingBuckets, id)
delete(c.dirty, id)
}
// rekey moves the memo when a peer's key changes so the next poll does not
// count the new key's zeroed counters as a reset.
func (c *collector) rekey(oldKey string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.memo, oldKey)
}
func (c *collector) poll(ctx context.Context) {
dev, err := c.e.be.Device(ctx)
if err != nil {
c.e.log.Warn("poll", "error", err)
return
}
now := time.Now()
window := time.Duration(c.e.Settings().ConnectedWindow) * time.Second
c.e.mu.RLock()
peers := make([]*store.Peer, 0, len(c.e.peers))
for _, p := range c.e.peers {
peers = append(peers, p)
}
c.e.mu.RUnlock()
seen := map[string]struct{}{}
for _, ps := range dev.Peers {
seen[ps.PublicKey.String()] = struct{}{}
}
c.mu.Lock()
defer c.mu.Unlock()
snap := Snapshot{At: now, Peers: make(map[string]Live, len(peers))}
snap.Totals.Peers = len(peers)
byKey := map[string]int{}
for i, ps := range dev.Peers {
byKey[ps.PublicKey.String()] = i
}
for _, p := range peers {
l, ok := c.live[p.ID]
if !ok {
l = &Live{PeerID: p.ID, Rx: p.RxTotal, Tx: p.TxTotal, LastHandshake: p.LastHandshake, Endpoint: p.LastEndpoint}
c.live[p.ID] = l
}
l.RxRate, l.TxRate = 0, 0
if active(p, now) {
snap.Totals.Active++
}
if i, ok := byKey[p.PublicKey]; ok {
ps := dev.Peers[i]
m, had := c.memo[p.PublicKey]
var drx, dtx int64
if had {
drx, dtx = ps.ReceiveBytes-m.rx, ps.TransmitBytes-m.tx
// A counter smaller than last time means the peer was
// removed and re-added: the new value is all new traffic.
if drx < 0 {
drx = ps.ReceiveBytes
}
if dtx < 0 {
dtx = ps.TransmitBytes
}
dt := now.Sub(m.at).Seconds()
if dt > 0 {
l.RxRate = float64(drx) / dt
l.TxRate = float64(dtx) / dt
}
} else {
// First sight of this key since start: whatever the device
// already counted happened before we were watching, unless
// the peer was just created, in which case it is zero anyway.
// Either way it must not be added to the persisted total, so
// only the memo is set.
drx, dtx = 0, 0
}
c.memo[p.PublicKey] = counterMemo{rx: ps.ReceiveBytes, tx: ps.TransmitBytes, at: now}
if drx > 0 || dtx > 0 {
l.Rx += drx
l.Tx += dtx
c.dirty[p.ID] = true
bucket := now.Truncate(bucketSize).Unix()
pb, ok := c.pendingBuckets[p.ID]
if !ok {
pb = map[int64]*[2]int64{}
c.pendingBuckets[p.ID] = pb
}
b, ok := pb[bucket]
if !ok {
b = &[2]int64{}
pb[bucket] = b
}
b[0] += drx
b[1] += dtx
}
if !ps.LastHandshake.IsZero() && ps.LastHandshake.After(l.LastHandshake) {
l.LastHandshake = ps.LastHandshake
c.dirty[p.ID] = true
}
if ps.Endpoint != nil {
ep := ps.Endpoint.String()
if ep != l.Endpoint {
l.Endpoint = ep
c.dirty[p.ID] = true
}
}
// Connected means the *interface* has a recent handshake, not
// the remembered one: after a restart, or after a peer is
// disabled and re-enabled, everyone is disconnected until the
// client handshakes again, which is the truth.
connected := !ps.LastHandshake.IsZero() && now.Sub(ps.LastHandshake) < window
if connected && !l.Connected {
l.ConnectedSince = l.LastHandshake
}
if !connected {
l.ConnectedSince = time.Time{}
}
l.Connected = connected
} else {
// Not on the interface: disabled, expired or removed by hand.
l.Connected = false
l.ConnectedSince = time.Time{}
delete(c.memo, p.PublicKey)
}
if l.Connected {
snap.Totals.Connected++
}
snap.Totals.Rx += l.Rx
snap.Totals.Tx += l.Tx
snap.Totals.RxRate += l.RxRate
snap.Totals.TxRate += l.TxRate
snap.Peers[p.ID] = *l
}
c.last = snap
c.e.hub.Publish("status", snap)
}
// flush writes dirty totals and pending buckets to the database.
func (c *collector) flush(ctx context.Context) error {
c.mu.Lock()
var counters []store.PeerCounters
var samples []store.TrafficSample
for id := range c.dirty {
l := c.live[id]
if l == nil {
continue
}
counters = append(counters, store.PeerCounters{ID: id, RxTotal: l.Rx, TxTotal: l.Tx, LastHandshake: l.LastHandshake, LastEndpoint: l.Endpoint})
}
for id, pb := range c.pendingBuckets {
for bucket, v := range pb {
samples = append(samples, store.TrafficSample{PeerID: id, Bucket: time.Unix(bucket, 0), Rx: v[0], Tx: v[1]})
}
}
c.dirty = map[string]bool{}
c.pendingBuckets = map[string]map[int64]*[2]int64{}
c.mu.Unlock()
if err := c.e.st.FlushCounters(ctx, counters, samples); err != nil {
return err
}
// Keep the in-memory peer records' totals current so a later UpdatePeer
// does not carry stale numbers around (they are not written by it, but
// the API reads them).
c.e.mu.Lock()
for _, k := range counters {
if p, ok := c.e.peers[k.ID]; ok {
p.RxTotal, p.TxTotal, p.LastHandshake, p.LastEndpoint = k.RxTotal, k.TxTotal, k.LastHandshake, k.LastEndpoint
}
}
c.e.mu.Unlock()
return nil
}
+421
View File
@@ -0,0 +1,421 @@
// Package engine ties the pieces together: it owns the server key, brings the
// interface up, keeps the data plane in step with the database, reads
// counters, and answers the questions the API asks.
package engine
import (
"context"
"errors"
"fmt"
"log/slog"
"net/netip"
"sync"
"time"
"github.com/Coffey-Labs/WGX/internal/config"
"github.com/Coffey-Labs/WGX/internal/netcfg"
"github.com/Coffey-Labs/WGX/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg"
)
const (
serverKeySetting = "server_private_key"
bucketSize = 5 * time.Minute
flushEvery = 30 * time.Second
houseEvery = 30 * time.Second
)
// Engine is the long-running core.
type Engine struct {
cfg *config.Config
st *store.Store
be wg.Backend
log *slog.Logger
mu sync.RWMutex
settings Settings
serverKey wg.Key
startedAt time.Time
sysctls []netcfg.Result
egress string
fwErr string
peers map[string]*store.Peer // by id
byKey map[string]*store.Peer // by public key
col *collector
hub *Hub
cancel context.CancelFunc
wg sync.WaitGroup
}
// New wires an engine up without starting anything.
func New(cfg *config.Config, st *store.Store, be wg.Backend, log *slog.Logger) *Engine {
e := &Engine{cfg: cfg, st: st, be: be, log: log, peers: map[string]*store.Peer{}, byKey: map[string]*store.Peer{}, hub: NewHub()}
e.col = newCollector(e)
return e
}
// Store exposes the database to the HTTP layer for users, sessions and audit.
func (e *Engine) Store() *store.Store { return e.st }
// Config exposes the process configuration.
func (e *Engine) Config() *config.Config { return e.cfg }
// Hub is the live-update fan-out.
func (e *Engine) Hub() *Hub { return e.hub }
// Backend names the data plane in use.
func (e *Engine) Backend() string { return e.be.Kind() }
// ServerPublicKey is what clients put in their [Peer] section.
func (e *Engine) ServerPublicKey() string {
e.mu.RLock()
defer e.mu.RUnlock()
return e.serverKey.PublicKey().String()
}
// Settings returns a copy of the current settings.
func (e *Engine) Settings() Settings {
e.mu.RLock()
defer e.mu.RUnlock()
return e.settings
}
// ServerAddresses are the interface's own tunnel addresses.
func (e *Engine) ServerAddresses() []netip.Prefix {
var out []netip.Prefix
out = append(out, netip.PrefixFrom(e.cfg.Subnet4.Masked().Addr().Next(), e.cfg.Subnet4.Bits()))
if e.cfg.Subnet6.IsValid() {
out = append(out, netip.PrefixFrom(e.cfg.Subnet6.Masked().Addr().Next(), e.cfg.Subnet6.Bits()))
}
return out
}
func (e *Engine) tunnelSubnets() []netip.Prefix {
out := []netip.Prefix{e.cfg.Subnet4.Masked()}
if e.cfg.Subnet6.IsValid() {
out = append(out, e.cfg.Subnet6.Masked())
}
return out
}
// Start loads state, brings the interface up and starts the background
// loops. It is safe to call Stop after a failed Start.
func (e *Engine) Start(ctx context.Context) error {
if err := e.loadSettings(ctx); err != nil {
return err
}
if err := e.loadServerKey(ctx); err != nil {
return err
}
if err := e.loadPeers(ctx); err != nil {
return err
}
if e.cfg.ManageSysctl && e.be.Kind() != "mock" {
results, err := netcfg.ApplyAll(netcfg.Wanted(e.cfg.Subnet6.IsValid()))
e.mu.Lock()
e.sysctls = results
e.mu.Unlock()
for _, r := range results {
if !r.Applied {
e.log.Warn("sysctl not applied", "key", r.Key, "wanted", r.Value, "current", r.Current, "error", r.Err, "why", r.Why)
}
}
if err != nil {
return err
}
}
settings := e.Settings()
dev := wg.DeviceConfig{PrivateKey: e.serverKey, ListenPort: e.cfg.ListenPort}
if err := e.be.Up(ctx, dev, e.ServerAddresses(), settings.MTU); err != nil {
return err
}
e.log.Info("interface up", "iface", e.cfg.Iface, "backend", e.be.Kind(), "port", e.cfg.ListenPort, "addresses", e.ServerAddresses(), "mtu", settings.MTU)
if err := e.applyFirewall(ctx); err != nil {
// Not fatal: the operator may run their own NAT. It is reported in
// the UI and the log so nobody wonders why peers cannot reach out.
e.log.Error("firewall rules not applied", "error", err)
e.mu.Lock()
e.fwErr = err.Error()
e.mu.Unlock()
}
if err := e.reconcile(ctx); err != nil {
return err
}
e.mu.Lock()
e.startedAt = time.Now()
e.mu.Unlock()
loopCtx, cancel := context.WithCancel(context.Background())
e.cancel = cancel
e.wg.Add(2)
go func() { defer e.wg.Done(); e.col.run(loopCtx) }()
go func() { defer e.wg.Done(); e.housekeeping(loopCtx) }()
return nil
}
// Stop halts the loops, flushes counters and tears the interface down.
func (e *Engine) Stop(ctx context.Context) error {
if e.cancel != nil {
e.cancel()
e.wg.Wait()
}
var errs []error
if err := e.col.flush(ctx); err != nil {
errs = append(errs, err)
}
if e.cfg.ManageFirewall && e.be.Kind() != "mock" {
if err := netcfg.Remove(ctx, ""); err != nil {
errs = append(errs, err)
}
}
if err := e.be.Down(ctx); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func (e *Engine) loadSettings(ctx context.Context) error {
s, ok, err := loadSettings(ctx, e.st)
if err != nil {
return err
}
if !ok {
def := DefaultSettings(e.cfg.InitialEndpoint, e.cfg.InitialDNS, e.cfg.ListenPort)
s = &def
if err := saveSettings(ctx, e.st, s); err != nil {
return err
}
}
e.mu.Lock()
e.settings = *s
e.mu.Unlock()
return nil
}
func (e *Engine) loadServerKey(ctx context.Context) error {
raw, err := e.st.GetSetting(ctx, serverKeySetting)
if err != nil {
return err
}
var key wg.Key
if raw == "" {
key, err = wg.GeneratePrivateKey()
if err != nil {
return err
}
if err := e.st.SetSetting(ctx, serverKeySetting, key.String()); err != nil {
return err
}
e.log.Info("generated server key", "publicKey", key.PublicKey().String())
} else {
key, err = wg.ParseKey(raw)
if err != nil {
return fmt.Errorf("stored server key is invalid: %w", err)
}
}
e.mu.Lock()
e.serverKey = key
e.mu.Unlock()
return nil
}
func (e *Engine) loadPeers(ctx context.Context) error {
peers, err := e.st.ListPeers(ctx)
if err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
e.peers = make(map[string]*store.Peer, len(peers))
e.byKey = make(map[string]*store.Peer, len(peers))
for _, p := range peers {
e.peers[p.ID] = p
e.byKey[p.PublicKey] = p
}
return nil
}
func (e *Engine) applyFirewall(ctx context.Context) error {
if !e.cfg.ManageFirewall || e.be.Kind() == "mock" {
return nil
}
egress := e.cfg.Egress
if egress == "" {
if d, err := netcfg.DefaultEgress(); err == nil {
egress = d
} else {
e.log.Warn("could not detect the egress interface; masquerading on every non-tunnel interface", "error", err)
}
}
s := e.Settings()
rules := netcfg.Rules{
Iface: e.cfg.Iface,
Egress: egress,
ListenPort: e.cfg.ListenPort,
Subnets: e.tunnelSubnets(),
PeerIsolation: s.PeerIsolation,
ClampMSS: s.ClampMSS,
}
if err := netcfg.Apply(ctx, rules); err != nil {
return err
}
e.mu.Lock()
e.egress = egress
e.fwErr = ""
e.mu.Unlock()
e.log.Info("firewall rules applied", "egress", egress, "peerIsolation", s.PeerIsolation, "clampMSS", s.ClampMSS)
return nil
}
// UpdateSettings validates, persists and applies new settings.
func (e *Engine) UpdateSettings(ctx context.Context, s Settings) error {
if err := s.Validate(); err != nil {
return err
}
old := e.Settings()
if err := saveSettings(ctx, e.st, &s); err != nil {
return err
}
e.mu.Lock()
e.settings = s
e.mu.Unlock()
if s.MTU != old.MTU {
if err := e.be.SetMTU(ctx, s.MTU); err != nil {
e.log.Warn("could not change interface MTU", "error", err)
}
}
if s.PeerIsolation != old.PeerIsolation || s.ClampMSS != old.ClampMSS {
if err := e.applyFirewall(ctx); err != nil {
e.mu.Lock()
e.fwErr = err.Error()
e.mu.Unlock()
return fmt.Errorf("settings saved but firewall rules failed: %w", err)
}
}
e.hub.Publish("settings", s)
return nil
}
// active reports whether a peer should currently be on the interface.
func active(p *store.Peer, now time.Time) bool {
if !p.Enabled {
return false
}
if !p.ExpiresAt.IsZero() && now.After(p.ExpiresAt) {
return false
}
return true
}
func (e *Engine) peerConfig(p *store.Peer) (wg.PeerConfig, error) {
pub, err := wg.ParseKey(p.PublicKey)
if err != nil {
return wg.PeerConfig{}, err
}
pc := wg.PeerConfig{PublicKey: pub}
if p.PresharedKey != "" {
psk, err := wg.ParseKey(p.PresharedKey)
if err != nil {
return wg.PeerConfig{}, err
}
pc.PresharedKey = &psk
}
if a, err := netip.ParseAddr(p.IPv4); err == nil {
pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 32))
}
if p.IPv6 != "" {
if a, err := netip.ParseAddr(p.IPv6); err == nil {
pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 128))
}
}
return pc, nil
}
// reconcile makes the interface's peer set match the database, one peer at
// a time. It never uses ReplacePeers on a running interface: that would
// reset every counter and drop every session for the sake of one change.
func (e *Engine) reconcile(ctx context.Context) error {
dev, err := e.be.Device(ctx)
if err != nil {
return err
}
now := time.Now()
e.mu.RLock()
want := make(map[string]wg.PeerConfig, len(e.peers))
for _, p := range e.peers {
if active(p, now) {
pc, err := e.peerConfig(p)
if err != nil {
e.log.Warn("skipping peer with bad key", "peer", p.ID, "error", err)
continue
}
want[p.PublicKey] = pc
}
}
e.mu.RUnlock()
have := make(map[string]wg.PeerState, len(dev.Peers))
for _, p := range dev.Peers {
have[p.PublicKey.String()] = p
}
var errs []error
for key, pc := range want {
cur, ok := have[key]
if ok && samePeer(cur, pc) {
continue
}
if err := e.be.SetPeer(ctx, pc); err != nil {
errs = append(errs, fmt.Errorf("add peer %s: %w", key, err))
}
}
for key, cur := range have {
if _, ok := want[key]; !ok {
if err := e.be.RemovePeer(ctx, cur.PublicKey); err != nil {
errs = append(errs, fmt.Errorf("remove peer %s: %w", key, err))
}
}
}
return errors.Join(errs...)
}
func samePeer(cur wg.PeerState, want wg.PeerConfig) bool {
if len(cur.AllowedIPs) != len(want.AllowedIPs) {
return false
}
set := map[netip.Prefix]bool{}
for _, a := range cur.AllowedIPs {
set[a] = true
}
for _, a := range want.AllowedIPs {
if !set[a] {
return false
}
}
return cur.PersistentKeepalive == want.PersistentKeepalive
}
func (e *Engine) housekeeping(ctx context.Context) {
t := time.NewTicker(houseEvery)
defer t.Stop()
prune := time.NewTicker(time.Hour)
defer prune.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := e.reconcile(ctx); err != nil {
e.log.Warn("reconcile", "error", err)
}
if err := e.col.flush(ctx); err != nil {
e.log.Warn("flush counters", "error", err)
}
_ = e.st.PruneSessions(ctx)
case <-prune.C:
_ = e.st.PruneTraffic(ctx, time.Now().Add(-e.cfg.TrafficRetention))
_ = e.st.PruneAudit(ctx, 5000)
}
}
}
+308
View File
@@ -0,0 +1,308 @@
package engine
import (
"context"
"io"
"log/slog"
"net/netip"
"strings"
"testing"
"time"
"github.com/Coffey-Labs/WGX/internal/config"
"github.com/Coffey-Labs/WGX/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg"
)
func testConfig() *config.Config {
return &config.Config{
DBPath: ":memory:",
Backend: "mock",
Iface: "wg0",
ListenPort: 51820,
Subnet4: netip.MustParsePrefix("10.8.0.0/29"),
Subnet6: netip.MustParsePrefix("fd42::/64"),
HTTP: "127.0.0.1:0",
SessionIdle: time.Hour,
SessionMax: 24 * time.Hour,
TrafficRetention: 24 * time.Hour,
PollInterval: time.Hour, // tests drive polls by hand
InitialEndpoint: "vpn.example.com",
InitialDNS: "1.1.1.1",
ManageFirewall: true,
ManageSysctl: true,
}
}
func newTestEngine(t *testing.T) (*Engine, *wg.Mock) {
t.Helper()
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
mock := wg.NewMock("wg0", false)
e := New(testConfig(), st, mock, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err := e.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = e.Stop(context.Background()) })
return e, mock
}
func TestAllocate(t *testing.T) {
subnet := netip.MustParsePrefix("10.8.0.0/29") // .0 net, .1 server, .2-.6 usable, .7 broadcast
used := map[string]bool{}
var got []string
for {
a, err := allocate(subnet, used)
if err != nil {
break
}
used[a.String()] = true
got = append(got, a.String())
}
want := "10.8.0.2 10.8.0.3 10.8.0.4 10.8.0.5 10.8.0.6"
if strings.Join(got, " ") != want {
t.Fatalf("got %v, want %s", got, want)
}
a6, err := allocate(netip.MustParsePrefix("fd42::/64"), map[string]bool{"fd42::2": true})
if err != nil || a6.String() != "fd42::3" {
t.Fatalf("v6 allocation %v %v", a6, err)
}
if _, err := checkAddress(subnet, "10.8.0.1", used); err == nil {
t.Fatal("server address accepted")
}
if _, err := checkAddress(subnet, "10.9.0.1", used); err == nil {
t.Fatal("outside address accepted")
}
}
func TestCreatePeerAndConfig(t *testing.T) {
e, mock := newTestEngine(t)
ctx := context.Background()
p, err := e.CreatePeer(ctx, PeerInput{Name: "Laptop"})
if err != nil {
t.Fatal(err)
}
if p.IPv4 != "10.8.0.2" || p.IPv6 != "fd42::2" {
t.Fatalf("addresses %s %s", p.IPv4, p.IPv6)
}
if p.PrivateKey == "" || p.PresharedKey == "" {
t.Fatal("server-managed peer should have private and preshared keys")
}
cfg := e.ClientConfig(p)
for _, want := range []string{
"PrivateKey = " + p.PrivateKey,
"Address = 10.8.0.2/29, fd42::2/64",
"DNS = 1.1.1.1",
"MTU = 1420",
"PublicKey = " + e.ServerPublicKey(),
"PresharedKey = " + p.PresharedKey,
"AllowedIPs = 0.0.0.0/0, ::/0",
"Endpoint = vpn.example.com:51820",
"PersistentKeepalive = 25",
} {
if !strings.Contains(cfg, want) {
t.Errorf("config missing %q:\n%s", want, cfg)
}
}
dev, _ := mock.Device(ctx)
if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != p.PublicKey {
t.Fatal("peer not applied to the interface")
}
if len(dev.Peers[0].AllowedIPs) != 2 {
t.Fatalf("allowed ips %v", dev.Peers[0].AllowedIPs)
}
png, err := e.QRCode(p, 256)
if err != nil || len(png) < 100 {
t.Fatalf("qr: %v", err)
}
// A client-keyed peer: no private key, no QR code.
priv, _ := wg.GeneratePrivateKey()
c, err := e.CreatePeer(ctx, PeerInput{Name: "Router", PublicKey: priv.PublicKey().String(), ClientRoutes: "10.8.0.0/29"})
if err != nil {
t.Fatal(err)
}
if c.PrivateKey != "" || !strings.Contains(e.ClientConfig(c), "<your private key>") {
t.Fatal("client-keyed peer leaked or lacked placeholder")
}
if _, err := e.QRCode(c, 256); err == nil {
t.Fatal("QR for client-keyed peer should fail")
}
if _, err := e.CreatePeer(ctx, PeerInput{Name: "Dup", PublicKey: priv.PublicKey().String()}); err == nil {
t.Fatal("duplicate public key accepted")
}
if _, err := e.CreatePeer(ctx, PeerInput{Name: ""}); err == nil {
t.Fatal("empty name accepted")
}
}
func TestDisableResetRotateDelete(t *testing.T) {
e, mock := newTestEngine(t)
ctx := context.Background()
p, _ := e.CreatePeer(ctx, PeerInput{Name: "Phone"})
pub, _ := wg.ParseKey(p.PublicKey)
mock.Touch(pub, 1000, 2000, "203.0.113.5:1234")
e.col.poll(ctx)
l, ok := e.Live(p.ID)
if !ok || !l.Connected || l.Endpoint != "203.0.113.5:1234" {
t.Fatalf("live state %+v", l)
}
// First sight sets the memo only; the next delta counts.
mock.Touch(pub, 500, 700, "")
e.col.poll(ctx)
l, _ = e.Live(p.ID)
if l.Rx != 500 || l.Tx != 700 {
t.Fatalf("totals %d/%d, want 500/700", l.Rx, l.Tx)
}
if _, err := e.SetEnabled(ctx, p.ID, false); err != nil {
t.Fatal(err)
}
dev, _ := mock.Device(ctx)
if len(dev.Peers) != 0 {
t.Fatal("disabled peer still on interface")
}
e.col.poll(ctx)
if l, _ := e.Live(p.ID); l.Connected {
t.Fatal("disabled peer reported connected")
}
if _, err := e.SetEnabled(ctx, p.ID, true); err != nil {
t.Fatal(err)
}
if dev, _ = mock.Device(ctx); len(dev.Peers) != 1 {
t.Fatal("enabled peer not back on interface")
}
// Counters restart at zero after re-add; the total must not go backwards.
mock.Touch(pub, 100, 100, "")
e.col.poll(ctx)
mock.Touch(pub, 100, 100, "")
e.col.poll(ctx)
l, _ = e.Live(p.ID)
if l.Rx != 600 || l.Tx != 800 {
t.Fatalf("totals after re-add %d/%d, want 600/800", l.Rx, l.Tx)
}
if err := e.ResetSession(ctx, p.ID); err != nil {
t.Fatal(err)
}
r, err := e.RotateKeys(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if r.PublicKey == p.PublicKey {
t.Fatal("key did not change")
}
dev, _ = mock.Device(ctx)
if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != r.PublicKey {
t.Fatal("rotated key not applied")
}
if err := e.col.flush(ctx); err != nil {
t.Fatal(err)
}
if err := e.DeletePeer(ctx, p.ID); err != nil {
t.Fatal(err)
}
if _, err := e.Peer(p.ID); err != ErrNotFound {
t.Fatal("deleted peer still present")
}
if dev, _ = mock.Device(ctx); len(dev.Peers) != 0 {
t.Fatal("deleted peer still on interface")
}
}
func TestExpiryAndReconcile(t *testing.T) {
e, mock := newTestEngine(t)
ctx := context.Background()
past := time.Now().Add(-time.Minute)
p, err := e.CreatePeer(ctx, PeerInput{Name: "Temp", ExpiresAt: &past})
if err != nil {
t.Fatal(err)
}
if dev, _ := mock.Device(ctx); len(dev.Peers) != 0 {
t.Fatal("expired peer applied")
}
future := time.Now().Add(time.Hour)
if _, err := e.UpdatePeer(ctx, p.ID, PeerInput{Name: "Temp", ExpiresAt: &future}); err != nil {
t.Fatal(err)
}
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
t.Fatal("un-expired peer not applied")
}
// Someone removes the peer by hand; reconcile puts it back.
pub, _ := wg.ParseKey(p.PublicKey)
_ = mock.RemovePeer(ctx, pub)
if err := e.reconcile(ctx); err != nil {
t.Fatal(err)
}
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
t.Fatal("reconcile did not restore peer")
}
// A stranger appears on the interface; reconcile removes it.
stray, _ := wg.GeneratePrivateKey()
_ = mock.SetPeer(ctx, wg.PeerConfig{PublicKey: stray.PublicKey()})
if err := e.reconcile(ctx); err != nil {
t.Fatal(err)
}
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
t.Fatal("reconcile did not remove stray peer")
}
}
func TestSettingsValidation(t *testing.T) {
e, _ := newTestEngine(t)
s := e.Settings()
s.EndpointHost = ""
if err := e.UpdateSettings(context.Background(), s); err == nil {
t.Fatal("empty endpoint accepted")
}
s = e.Settings()
s.MTU = 100
if err := e.UpdateSettings(context.Background(), s); err == nil {
t.Fatal("tiny MTU accepted")
}
s = e.Settings()
s.DNS = "1.1.1.1, example.com"
s.MTU = 1380
if err := e.UpdateSettings(context.Background(), s); err != nil {
t.Fatal(err)
}
if e.Settings().MTU != 1380 {
t.Fatal("settings not persisted")
}
}
func TestPersistenceAcrossRestart(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()
e1 := New(testConfig(), st, wg.NewMock("wg0", false), log)
if err := e1.Start(ctx); err != nil {
t.Fatal(err)
}
p, _ := e1.CreatePeer(ctx, PeerInput{Name: "Keep"})
key := e1.ServerPublicKey()
_ = e1.Stop(ctx)
e2 := New(testConfig(), st, wg.NewMock("wg0", false), log)
if err := e2.Start(ctx); err != nil {
t.Fatal(err)
}
defer e2.Stop(ctx)
if e2.ServerPublicKey() != key {
t.Fatal("server key changed across restart")
}
got, err := e2.Peer(p.ID)
if err != nil || got.PublicKey != p.PublicKey {
t.Fatal("peer lost across restart")
}
if dev, _ := e2.be.Device(ctx); len(dev.Peers) != 1 {
t.Fatal("peer not re-applied after restart")
}
}
+60
View File
@@ -0,0 +1,60 @@
package engine
import (
"encoding/json"
"sync"
)
// Event is one server-sent event.
type Event struct {
Name string
Data []byte
}
// Hub fans events out to every open dashboard. A subscriber that cannot keep
// up loses events rather than stalling the collector; the next status
// snapshot carries the full picture anyway.
type Hub struct {
mu sync.Mutex
subs map[chan Event]struct{}
}
// NewHub returns an empty hub.
func NewHub() *Hub { return &Hub{subs: map[chan Event]struct{}{}} }
// Subscribe returns a channel of events and a function to leave.
func (h *Hub) Subscribe() (<-chan Event, func()) {
ch := make(chan Event, 16)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
delete(h.subs, ch)
h.mu.Unlock()
}
}
// Publish encodes v as JSON and sends it to every subscriber.
func (h *Hub) Publish(name string, v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
ev := Event{Name: name, Data: data}
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs {
select {
case ch <- ev:
default:
}
}
}
// Subscribers is how many dashboards are listening.
func (h *Hub) Subscribers() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs)
}
+486
View File
@@ -0,0 +1,486 @@
package engine
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"sort"
"strconv"
"strings"
"time"
"github.com/skip2/go-qrcode"
"github.com/Coffey-Labs/WGX/internal/auth"
"github.com/Coffey-Labs/WGX/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg"
)
// PeerInput is what the API accepts when creating or editing a peer.
type PeerInput struct {
Name string `json:"name"`
// PublicKey, when set on create, means the client generated its own key
// pair and the server never sees the private key.
PublicKey string `json:"publicKey,omitempty"`
// IPv4 / IPv6 may pin addresses; empty means allocate.
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
ClientRoutes string `json:"clientRoutes"`
DNS string `json:"dns"`
Keepalive *int `json:"keepalive"`
MTU *int `json:"mtu"`
Enabled *bool `json:"enabled"`
ExpiresAt *time.Time `json:"expiresAt"`
Notes string `json:"notes"`
}
// ErrValidation marks user errors so the API can answer 400.
type ErrValidation struct{ Msg string }
func (e ErrValidation) Error() string { return e.Msg }
func invalid(format string, a ...any) error { return ErrValidation{Msg: fmt.Sprintf(format, a...)} }
// ErrNotFound is returned for unknown peer ids.
var ErrNotFound = store.ErrNotFound
// Peer returns a copy of one peer.
func (e *Engine) Peer(id string) (*store.Peer, error) {
e.mu.RLock()
defer e.mu.RUnlock()
p, ok := e.peers[id]
if !ok {
return nil, ErrNotFound
}
cp := *p
return &cp, nil
}
// Peers returns copies of every peer, newest first.
func (e *Engine) Peers() []*store.Peer {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]*store.Peer, 0, len(e.peers))
for _, p := range e.peers {
cp := *p
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].CreatedAt.After(out[j].CreatedAt)
}
return out[i].ID < out[j].ID
})
return out
}
// Live returns the live state for a peer.
func (e *Engine) Live(id string) (Live, bool) { return e.col.LiveFor(id) }
// Snapshot returns the last poll.
func (e *Engine) Snapshot() Snapshot { return e.col.Snapshot() }
func (e *Engine) usedAddresses() (v4, v6 map[string]bool) {
v4, v6 = map[string]bool{}, map[string]bool{}
e.mu.RLock()
defer e.mu.RUnlock()
for _, p := range e.peers {
v4[p.IPv4] = true
if p.IPv6 != "" {
v6[p.IPv6] = true
}
}
return
}
func validateName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", invalid("name is required")
}
if len(name) > 64 {
return "", invalid("name must be 64 characters or fewer")
}
return name, nil
}
// CreatePeer allocates addresses, generates keys and adds the peer to the
// interface. The returned peer includes the private key when the server
// generated it.
func (e *Engine) CreatePeer(ctx context.Context, in PeerInput) (*store.Peer, error) {
name, err := validateName(in.Name)
if err != nil {
return nil, err
}
settings := e.Settings()
p := &store.Peer{Name: name, Enabled: true, Notes: strings.TrimSpace(in.Notes)}
if p.ID, err = auth.NewID(); err != nil {
return nil, err
}
if in.PublicKey != "" {
pub, err := wg.ParseKey(strings.TrimSpace(in.PublicKey))
if err != nil {
return nil, invalid("public key: %v", err)
}
p.PublicKey = pub.String()
} else {
priv, err := wg.GeneratePrivateKey()
if err != nil {
return nil, err
}
p.PrivateKey = priv.String()
p.PublicKey = priv.PublicKey().String()
}
if p.PublicKey == e.ServerPublicKey() {
return nil, invalid("that is the server's own public key")
}
e.mu.RLock()
_, dup := e.byKey[p.PublicKey]
e.mu.RUnlock()
if dup {
return nil, invalid("a peer with that public key already exists")
}
if settings.PresharedKeys {
psk, err := wg.GeneratePresharedKey()
if err != nil {
return nil, err
}
p.PresharedKey = psk.String()
}
used4, used6 := e.usedAddresses()
var a4 netip.Addr
if in.IPv4 != "" {
if a4, err = checkAddress(e.cfg.Subnet4, in.IPv4, used4); err != nil {
return nil, invalid("IPv4: %v", err)
}
} else if a4, err = allocate(e.cfg.Subnet4, used4); err != nil {
return nil, invalid("%v", err)
}
p.IPv4 = a4.String()
if e.cfg.Subnet6.IsValid() {
var a6 netip.Addr
if in.IPv6 != "" {
if a6, err = checkAddress(e.cfg.Subnet6, in.IPv6, used6); err != nil {
return nil, invalid("IPv6: %v", err)
}
} else if a6, err = allocate(e.cfg.Subnet6, used6); err != nil {
return nil, invalid("%v", err)
}
p.IPv6 = a6.String()
} else if in.IPv6 != "" {
return nil, invalid("IPv6 is not enabled on this server (set WGX_SUBNET6)")
}
if err := applyEditable(p, in, settings); err != nil {
return nil, err
}
if err := e.st.CreatePeer(ctx, p); err != nil {
return nil, err
}
e.mu.Lock()
e.peers[p.ID] = p
e.byKey[p.PublicKey] = p
e.mu.Unlock()
if err := e.applyPeer(ctx, p); err != nil {
e.log.Error("apply new peer", "peer", p.ID, "error", err)
}
e.hub.Publish("peers", "changed")
cp := *p
return &cp, nil
}
// applyEditable copies the fields that may change after creation.
func applyEditable(p *store.Peer, in PeerInput, settings Settings) error {
routes := strings.TrimSpace(in.ClientRoutes)
if routes == "" {
routes = settings.ClientRoutes
}
ps, err := ParsePrefixes(routes)
if err != nil {
return invalid("client routes: %v", err)
}
p.ClientRoutes = JoinPrefixes(ps)
if _, err := ParseDNS(in.DNS); err != nil {
return invalid("%v", err)
}
p.DNS = strings.TrimSpace(in.DNS)
if in.Keepalive != nil {
if *in.Keepalive < 0 || *in.Keepalive > 65535 {
return invalid("keepalive must be 0-65535 seconds")
}
p.Keepalive = *in.Keepalive
}
if in.MTU != nil {
if *in.MTU != 0 && (*in.MTU < 1280 || *in.MTU > 9000) {
return invalid("MTU must be 0 (server default) or 1280-9000")
}
p.MTU = *in.MTU
}
if in.Enabled != nil {
p.Enabled = *in.Enabled
}
if in.ExpiresAt != nil {
p.ExpiresAt = in.ExpiresAt.UTC()
if p.ExpiresAt.Unix() <= 0 {
p.ExpiresAt = time.Time{}
}
}
p.Notes = strings.TrimSpace(in.Notes)
if len(p.Notes) > 2000 {
return invalid("notes must be 2000 characters or fewer")
}
return nil
}
// UpdatePeer edits a peer. Keys and addresses do not change here.
func (e *Engine) UpdatePeer(ctx context.Context, id string, in PeerInput) (*store.Peer, error) {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return nil, ErrNotFound
}
p := *cur
name, err := validateName(in.Name)
if err != nil {
return nil, err
}
p.Name = name
if err := applyEditable(&p, in, e.Settings()); err != nil {
return nil, err
}
if err := e.st.UpdatePeer(ctx, &p); err != nil {
return nil, err
}
e.mu.Lock()
*cur = p
e.mu.Unlock()
if err := e.applyPeer(ctx, cur); err != nil {
e.log.Error("apply peer", "peer", id, "error", err)
}
e.hub.Publish("peers", "changed")
return &p, nil
}
// SetEnabled turns a peer on or off. Off removes it from the interface at
// once, which drops any session it has: this is "disconnect".
func (e *Engine) SetEnabled(ctx context.Context, id string, enabled bool) (*store.Peer, error) {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return nil, ErrNotFound
}
p := *cur
p.Enabled = enabled
if err := e.st.UpdatePeer(ctx, &p); err != nil {
return nil, err
}
e.mu.Lock()
*cur = p
e.mu.Unlock()
if err := e.applyPeer(ctx, cur); err != nil {
return nil, err
}
e.hub.Publish("peers", "changed")
return &p, nil
}
// ResetSession drops a peer's current session without disabling it. The
// client will handshake again on its next packet.
func (e *Engine) ResetSession(ctx context.Context, id string) error {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return ErrNotFound
}
pub, err := wg.ParseKey(cur.PublicKey)
if err != nil {
return err
}
if err := e.be.RemovePeer(ctx, pub); err != nil {
return err
}
e.col.rekey(cur.PublicKey)
return e.applyPeer(ctx, cur)
}
// RotateKeys gives a server-managed peer a new key pair (and preshared key).
// The old configuration stops working immediately.
func (e *Engine) RotateKeys(ctx context.Context, id string) (*store.Peer, error) {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return nil, ErrNotFound
}
if cur.PrivateKey == "" {
return nil, invalid("this peer's keys are managed by the client; create a new peer instead")
}
priv, err := wg.GeneratePrivateKey()
if err != nil {
return nil, err
}
p := *cur
oldKey := p.PublicKey
p.PrivateKey = priv.String()
p.PublicKey = priv.PublicKey().String()
if e.Settings().PresharedKeys {
psk, err := wg.GeneratePresharedKey()
if err != nil {
return nil, err
}
p.PresharedKey = psk.String()
} else {
p.PresharedKey = ""
}
if err := e.st.UpdatePeer(ctx, &p); err != nil {
return nil, err
}
if old, err := wg.ParseKey(oldKey); err == nil {
_ = e.be.RemovePeer(ctx, old)
}
e.col.rekey(oldKey)
e.mu.Lock()
delete(e.byKey, oldKey)
*cur = p
e.byKey[p.PublicKey] = cur
e.mu.Unlock()
if err := e.applyPeer(ctx, cur); err != nil {
e.log.Error("apply rotated peer", "peer", id, "error", err)
}
e.hub.Publish("peers", "changed")
return &p, nil
}
// DeletePeer removes a peer for good.
func (e *Engine) DeletePeer(ctx context.Context, id string) error {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return ErrNotFound
}
if pub, err := wg.ParseKey(cur.PublicKey); err == nil {
if err := e.be.RemovePeer(ctx, pub); err != nil {
e.log.Warn("remove peer from interface", "peer", id, "error", err)
}
}
if err := e.st.DeletePeer(ctx, id); err != nil {
return err
}
e.mu.Lock()
delete(e.peers, id)
delete(e.byKey, cur.PublicKey)
e.mu.Unlock()
e.col.forget(id, cur.PublicKey)
e.hub.Publish("peers", "changed")
return nil
}
// applyPeer adds or removes one peer on the interface according to whether
// it should be active.
func (e *Engine) applyPeer(ctx context.Context, p *store.Peer) error {
pc, err := e.peerConfig(p)
if err != nil {
return err
}
if active(p, time.Now()) {
return e.be.SetPeer(ctx, pc)
}
return e.be.RemovePeer(ctx, pc.PublicKey)
}
// ClientConfig renders the WireGuard configuration file for a peer. When the
// client holds its own private key the placeholder is left for them.
func (e *Engine) ClientConfig(p *store.Peer) string {
s := e.Settings()
var b strings.Builder
b.WriteString("[Interface]\n")
if p.PrivateKey != "" {
fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey)
} else {
b.WriteString("PrivateKey = <your private key>\n")
}
addrs := []string{fmt.Sprintf("%s/%d", p.IPv4, e.cfg.Subnet4.Bits())}
if p.IPv6 != "" && e.cfg.Subnet6.IsValid() {
addrs = append(addrs, fmt.Sprintf("%s/%d", p.IPv6, e.cfg.Subnet6.Bits()))
}
fmt.Fprintf(&b, "Address = %s\n", strings.Join(addrs, ", "))
dns := p.DNS
if dns == "" {
dns = s.DNS
}
if d, _ := ParseDNS(dns); len(d) > 0 {
fmt.Fprintf(&b, "DNS = %s\n", strings.Join(d, ", "))
}
mtu := p.MTU
if mtu == 0 {
mtu = s.MTU
}
fmt.Fprintf(&b, "MTU = %d\n", mtu)
b.WriteString("\n[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", e.ServerPublicKey())
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.ClientRoutes)
fmt.Fprintf(&b, "Endpoint = %s\n", net.JoinHostPort(s.EndpointHost, strconv.Itoa(s.EndpointPort)))
ka := p.Keepalive
if ka == 0 {
ka = s.Keepalive
}
if ka > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", ka)
}
return b.String()
}
// QRCode renders the client configuration as a PNG.
func (e *Engine) QRCode(p *store.Peer, size int) ([]byte, error) {
if p.PrivateKey == "" {
return nil, errors.New("no QR code: the private key is held by the client")
}
if size < 128 || size > 1024 {
size = 384
}
return qrcode.Encode(e.ClientConfig(p), qrcode.Medium, size)
}
// Usage returns a peer's traffic series since a time.
func (e *Engine) Usage(ctx context.Context, peerID string, since time.Time) ([]store.TrafficPoint, error) {
if peerID != "" {
if _, err := e.Peer(peerID); err != nil {
return nil, err
}
}
// Pending buckets are flushed first so the last few minutes show.
if err := e.col.flush(ctx); err != nil {
return nil, err
}
pts, err := e.st.TrafficSeries(ctx, peerID, since)
if err != nil {
return nil, err
}
if pts == nil {
pts = []store.TrafficPoint{}
}
return pts, nil
}
// UsageByPeer sums traffic per peer since a time.
func (e *Engine) UsageByPeer(ctx context.Context, since time.Time) ([]store.PeerUsage, error) {
if err := e.col.flush(ctx); err != nil {
return nil, err
}
u, err := e.st.UsageSince(ctx, since)
if err != nil {
return nil, err
}
if u == nil {
u = []store.PeerUsage{}
}
return u, nil
}
+168
View File
@@ -0,0 +1,168 @@
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/netip"
"strings"
"github.com/Coffey-Labs/WGX/internal/store"
)
// Settings are the administrator-editable server options. They persist in the
// database and can be changed from the UI without restarting the container.
type Settings struct {
// EndpointHost is the public name or address clients connect to.
EndpointHost string `json:"endpointHost"`
// EndpointPort is what clients dial; usually the listen port, but
// different when the container's UDP port is remapped.
EndpointPort int `json:"endpointPort"`
// DNS handed to clients, comma separated. Empty means none.
DNS string `json:"dns"`
// ClientRoutes is the default AllowedIPs written into client configs.
ClientRoutes string `json:"clientRoutes"`
// MTU for the server interface and, by default, client configs.
MTU int `json:"mtu"`
// Keepalive is the default PersistentKeepalive for clients, in seconds.
Keepalive int `json:"keepalive"`
// PeerIsolation stops peers reaching one another.
PeerIsolation bool `json:"peerIsolation"`
// ClampMSS rewrites TCP MSS on forwarded SYNs to fit the tunnel MTU.
ClampMSS bool `json:"clampMSS"`
// PresharedKeys adds a per-peer preshared key to every new peer.
PresharedKeys bool `json:"presharedKeys"`
// ConnectedWindow is how many seconds since the last handshake still
// counts as connected. WireGuard rejects sessions after 180 s.
ConnectedWindow int `json:"connectedWindow"`
}
// DefaultSettings returns what a fresh install starts with.
func DefaultSettings(endpointHost, dns string, port int) Settings {
return Settings{
EndpointHost: endpointHost,
EndpointPort: port,
DNS: dns,
ClientRoutes: "0.0.0.0/0, ::/0",
MTU: 1420,
Keepalive: 25,
PeerIsolation: false,
ClampMSS: true,
PresharedKeys: true,
ConnectedWindow: 180,
}
}
// Validate checks settings coming in from the API.
func (s *Settings) Validate() error {
var errs []error
s.EndpointHost = strings.TrimSpace(s.EndpointHost)
if s.EndpointHost == "" {
errs = append(errs, errors.New("endpoint host is required"))
} else if strings.ContainsAny(s.EndpointHost, " /\\:") && !strings.HasPrefix(s.EndpointHost, "[") {
if _, err := netip.ParseAddr(s.EndpointHost); err != nil {
errs = append(errs, errors.New("endpoint host must be a hostname or IP address without a port"))
}
}
if s.EndpointPort < 1 || s.EndpointPort > 65535 {
errs = append(errs, errors.New("endpoint port must be 1-65535"))
}
if _, err := ParseDNS(s.DNS); err != nil {
errs = append(errs, err)
}
if _, err := ParsePrefixes(s.ClientRoutes); err != nil {
errs = append(errs, fmt.Errorf("client routes: %w", err))
}
if s.MTU < 1280 || s.MTU > 9000 {
errs = append(errs, errors.New("MTU must be between 1280 and 9000"))
}
if s.Keepalive < 0 || s.Keepalive > 65535 {
errs = append(errs, errors.New("keepalive must be 0-65535 seconds"))
}
if s.ConnectedWindow < 30 || s.ConnectedWindow > 3600 {
errs = append(errs, errors.New("connected window must be 30-3600 seconds"))
}
return errors.Join(errs...)
}
// ParseDNS validates a comma-separated list of resolvers (addresses, or a
// search domain which WireGuard clients also accept in the DNS field).
func ParseDNS(s string) ([]string, error) {
var out []string
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, err := netip.ParseAddr(part); err != nil {
// Allow search domains: letters, digits, dots and dashes only.
for _, r := range part {
if !(r == '.' || r == '-' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return nil, fmt.Errorf("DNS entry %q is neither an address nor a domain", part)
}
}
}
out = append(out, part)
}
return out, nil
}
// ParsePrefixes parses a comma-separated CIDR list; bare addresses become
// host prefixes.
func ParsePrefixes(s string) ([]netip.Prefix, error) {
var out []netip.Prefix
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
p, err := netip.ParsePrefix(part)
if err != nil {
a, err2 := netip.ParseAddr(part)
if err2 != nil {
return nil, fmt.Errorf("%q is not a CIDR", part)
}
p = netip.PrefixFrom(a, a.BitLen())
}
out = append(out, p.Masked())
}
if len(out) == 0 {
return nil, errors.New("at least one route is required")
}
return out, nil
}
// JoinPrefixes renders prefixes the way a WireGuard config expects.
func JoinPrefixes(ps []netip.Prefix) string {
parts := make([]string, len(ps))
for i, p := range ps {
parts[i] = p.String()
}
return strings.Join(parts, ", ")
}
const settingsKey = "server"
func loadSettings(ctx context.Context, st *store.Store) (*Settings, bool, error) {
raw, err := st.GetSetting(ctx, settingsKey)
if err != nil {
return nil, false, err
}
if raw == "" {
return nil, false, nil
}
var s Settings
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return nil, false, fmt.Errorf("settings are corrupt: %w", err)
}
return &s, true, nil
}
func saveSettings(ctx context.Context, st *store.Store, s *Settings) error {
raw, err := json.Marshal(s)
if err != nil {
return err
}
return st.SetSetting(ctx, settingsKey, string(raw))
}
+75
View File
@@ -0,0 +1,75 @@
package engine
import (
"time"
"github.com/Coffey-Labs/WGX/internal/netcfg"
)
// SysctlStatus is one sysctl as reported to the UI.
type SysctlStatus struct {
Key string `json:"key"`
Wanted string `json:"wanted"`
Current string `json:"current"`
Applied bool `json:"applied"`
Required bool `json:"required"`
Why string `json:"why"`
Error string `json:"error,omitempty"`
}
// Status is the server overview.
type Status struct {
Version string `json:"version"`
Backend string `json:"backend"`
Interface string `json:"interface"`
PublicKey string `json:"publicKey"`
ListenPort int `json:"listenPort"`
Addresses []string `json:"addresses"`
Subnet4 string `json:"subnet4"`
Subnet6 string `json:"subnet6,omitempty"`
Egress string `json:"egress,omitempty"`
FirewallError string `json:"firewallError,omitempty"`
FirewallManaged bool `json:"firewallManaged"`
StartedAt time.Time `json:"startedAt"`
Sysctls []SysctlStatus `json:"sysctls"`
Totals Totals `json:"totals"`
Settings Settings `json:"settings"`
}
// Version is stamped at build time.
var Version = "dev"
// Status assembles the overview.
func (e *Engine) Status() Status {
e.mu.RLock()
defer e.mu.RUnlock()
st := Status{
Version: Version,
Backend: e.be.Kind(),
Interface: e.cfg.Iface,
PublicKey: e.serverKey.PublicKey().String(),
ListenPort: e.cfg.ListenPort,
Subnet4: e.cfg.Subnet4.Masked().String(),
Egress: e.egress,
FirewallError: e.fwErr,
FirewallManaged: e.cfg.ManageFirewall && e.be.Kind() != "mock",
StartedAt: e.startedAt,
Settings: e.settings,
Sysctls: []SysctlStatus{},
}
if e.cfg.Subnet6.IsValid() {
st.Subnet6 = e.cfg.Subnet6.Masked().String()
}
for _, a := range e.ServerAddresses() {
st.Addresses = append(st.Addresses, a.String())
}
for _, r := range e.sysctls {
st.Sysctls = append(st.Sysctls, sysctlStatus(r))
}
st.Totals = e.col.Snapshot().Totals
return st
}
func sysctlStatus(r netcfg.Result) SysctlStatus {
return SysctlStatus{Key: r.Key, Wanted: r.Value, Current: r.Current, Applied: r.Applied, Required: r.Required, Why: r.Why, Error: r.Err}
}