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