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
+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
}