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
+336
View File
@@ -0,0 +1,336 @@
// Package server is the admin HTTP API and the host for the embedded UI.
package server
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"path"
"strings"
"time"
"github.com/Coffey-Labs/WGX/internal/auth"
"github.com/Coffey-Labs/WGX/internal/config"
"github.com/Coffey-Labs/WGX/internal/engine"
"github.com/Coffey-Labs/WGX/internal/server/static"
)
// Server serves the API and UI.
type Server struct {
cfg *config.Config
eng *engine.Engine
log *slog.Logger
mux *http.ServeMux
ipLimit *auth.Limiter
usrLimit *auth.Limiter
http *http.Server
}
// New builds the router.
func New(cfg *config.Config, eng *engine.Engine, log *slog.Logger) *Server {
s := &Server{
cfg: cfg,
eng: eng,
log: log,
mux: http.NewServeMux(),
ipLimit: auth.NewLimiter(20, 15*time.Minute),
usrLimit: auth.NewLimiter(8, 15*time.Minute),
}
s.routes()
return s
}
// Handler returns the full middleware chain, for tests and for ListenAndServe.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.securityHeaders(s.mux))
}
// ListenAndServe runs until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context) error {
s.http = &http.Server{
Addr: s.cfg.HTTP,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// No WriteTimeout: the SSE stream is long-lived. Handlers that
// matter bound themselves.
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 64 << 10,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
}
var tlsCfg *tls.Config
if s.cfg.TLSEnabled() {
cert, err := s.loadCertificate()
if err != nil {
return err
}
tlsCfg = &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}
s.http.TLSConfig = tlsCfg
}
ln, err := net.Listen("tcp", s.cfg.HTTP)
if err != nil {
return fmt.Errorf("listen %s: %w", s.cfg.HTTP, err)
}
errCh := make(chan error, 1)
go func() {
if tlsCfg != nil {
errCh <- s.http.ServeTLS(ln, "", "")
} else {
errCh <- s.http.Serve(ln)
}
}()
s.log.Info("admin UI listening", "addr", ln.Addr().String(), "tls", tlsCfg != nil)
select {
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.http.Shutdown(shutdown)
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func (s *Server) routes() {
m := s.mux
// Unauthenticated.
m.HandleFunc("GET /api/health", s.handleHealth)
m.HandleFunc("GET /api/setup", s.handleSetupStatus)
m.HandleFunc("POST /api/setup", s.handleSetup)
m.HandleFunc("POST /api/auth/login", s.handleLogin)
m.HandleFunc("POST /api/auth/totp", s.handleLoginTOTP)
m.HandleFunc("GET /metrics", s.handleMetrics)
// Session required.
m.Handle("GET /api/auth/me", s.authed(s.handleMe))
m.Handle("POST /api/auth/logout", s.authed(s.handleLogout))
m.Handle("POST /api/auth/password", s.authed(s.handleChangePassword))
m.Handle("POST /api/auth/totp/setup", s.authed(s.handleTOTPSetup))
m.Handle("GET /api/auth/totp/qr.png", s.authed(s.handleTOTPQR))
m.Handle("POST /api/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
m.Handle("POST /api/auth/totp/disable", s.authed(s.handleTOTPDisable))
m.Handle("GET /api/auth/sessions", s.authed(s.handleSessions))
m.Handle("POST /api/auth/sessions/revoke", s.authed(s.handleRevokeSessions))
m.Handle("GET /api/status", s.authed(s.handleStatus))
m.Handle("GET /api/events", s.authed(s.handleEvents))
m.Handle("GET /api/peers", s.authed(s.handlePeers))
m.Handle("GET /api/peers/{id}", s.authed(s.handlePeer))
m.Handle("GET /api/peers/{id}/config", s.authed(s.handlePeerConfig))
m.Handle("GET /api/peers/{id}/qr.png", s.authed(s.handlePeerQR))
m.Handle("GET /api/peers/{id}/usage", s.authed(s.handlePeerUsage))
m.Handle("GET /api/usage", s.authed(s.handleUsage))
m.Handle("GET /api/usage/peers", s.authed(s.handleUsageByPeer))
m.Handle("GET /api/settings", s.authed(s.handleGetSettings))
m.Handle("GET /api/audit", s.authed(s.handleAudit))
m.Handle("GET /api/users", s.authed(s.handleUsers))
// Admin role required.
m.Handle("POST /api/peers", s.admin(s.handleCreatePeer))
m.Handle("PUT /api/peers/{id}", s.admin(s.handleUpdatePeer))
m.Handle("DELETE /api/peers/{id}", s.admin(s.handleDeletePeer))
m.Handle("POST /api/peers/{id}/enable", s.admin(s.handleEnablePeer))
m.Handle("POST /api/peers/{id}/disable", s.admin(s.handleDisablePeer))
m.Handle("POST /api/peers/{id}/reset", s.admin(s.handleResetPeer))
m.Handle("POST /api/peers/{id}/rotate", s.admin(s.handleRotatePeer))
m.Handle("PUT /api/settings", s.admin(s.handlePutSettings))
m.Handle("POST /api/users", s.admin(s.handleCreateUser))
m.Handle("PUT /api/users/{id}", s.admin(s.handleUpdateUser))
m.Handle("DELETE /api/users/{id}", s.admin(s.handleDeleteUser))
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "no such endpoint")
})
m.Handle("/", s.spa())
}
// spa serves the embedded UI, falling back to index.html for client routes.
func (s *Server) spa() http.Handler {
files := static.FS()
fileServer := http.FileServerFS(files)
index, _ := fs.ReadFile(files, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
p := path.Clean(r.URL.Path)
if p != "/" {
if f, err := files.Open(strings.TrimPrefix(p, "/")); err == nil {
f.Close()
if strings.HasPrefix(p, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
fileServer.ServeHTTP(w, r)
return
}
}
if index == nil {
http.Error(w, "the admin UI has not been built; run `npm run build` in web/", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Write(index)
})
}
// --- middleware ------------------------------------------------------------
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler {
panic(rec)
}
s.log.Error("panic", "path", r.URL.Path, "error", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
if s.cfg.TLSEnabled() || s.cfg.SecureCookies {
h.Set("Strict-Transport-Security", "max-age=31536000")
}
if strings.HasPrefix(r.URL.Path, "/api/") {
h.Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
// sameOrigin rejects cross-site state changes. Cookies are SameSite=Strict
// already; this is the belt to that brace, for browsers that send
// Sec-Fetch-Site or Origin.
func (s *Server) sameOrigin(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
}
if origin := r.Header.Get("Origin"); origin != "" {
host := r.Host
return strings.EqualFold(strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"), host)
}
// Neither header: not a modern browser. A non-browser client cannot be
// tricked by a third-party page, so allow it.
return true
}
// clientIP returns the caller's address, honouring proxy headers only from
// trusted proxies.
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return host
}
if !s.trusted(addr) {
return addr.String()
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
// Walk from the right, skipping trusted hops, to the first address
// that is not one of our proxies.
for i := len(parts) - 1; i >= 0; i-- {
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
if err != nil {
break
}
if !s.trusted(a) {
return a.String()
}
}
}
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
if a, err := netip.ParseAddr(real); err == nil {
return a.String()
}
}
return addr.String()
}
func (s *Server) trusted(a netip.Addr) bool {
for _, p := range s.cfg.TrustedProxies {
if p.Contains(a) {
return true
}
}
return false
}
// --- helpers ---------------------------------------------------------------
type errorBody struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
// readJSON decodes a small JSON body strictly.
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "expected application/json")
return false
}
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "bad JSON: "+err.Error())
return false
}
return true
}
// engineError maps engine errors to status codes.
func engineError(w http.ResponseWriter, err error) {
var ve engine.ErrValidation
switch {
case errors.As(err, &ve):
writeError(w, http.StatusBadRequest, ve.Msg)
case errors.Is(err, engine.ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
}