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
+402
View File
@@ -0,0 +1,402 @@
package server
import (
"crypto/subtle"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/Coffey-Labs/WGX/internal/engine"
"github.com/Coffey-Labs/WGX/internal/store"
)
type healthBody struct {
OK bool `json:"ok"`
Version string `json:"version"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, healthBody{OK: true, Version: engine.Version})
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.eng.Status())
}
// peerBody is a peer as the API presents it: never the private or preshared
// key (those only leave through the config and QR endpoints).
type peerBody struct {
ID string `json:"id"`
Name string `json:"name"`
PublicKey string `json:"publicKey"`
ServerKeys bool `json:"serverKeys"`
PresharedKey bool `json:"presharedKey"`
IPv4 string `json:"ipv4"`
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"`
Expired bool `json:"expired"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Live engine.Live `json:"live"`
}
func (s *Server) toPeerBody(p *store.Peer) peerBody {
b := peerBody{
ID: p.ID, Name: p.Name, PublicKey: p.PublicKey, ServerKeys: p.PrivateKey != "", PresharedKey: p.PresharedKey != "",
IPv4: p.IPv4, IPv6: p.IPv6, ClientRoutes: p.ClientRoutes, DNS: p.DNS, Keepalive: p.Keepalive, MTU: p.MTU,
Enabled: p.Enabled, Notes: p.Notes, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
}
if !p.ExpiresAt.IsZero() {
t := p.ExpiresAt
b.ExpiresAt = &t
b.Expired = time.Now().After(t)
}
if l, ok := s.eng.Live(p.ID); ok {
b.Live = l
} else {
b.Live = engine.Live{PeerID: p.ID, Rx: p.RxTotal, Tx: p.TxTotal, LastHandshake: p.LastHandshake, Endpoint: p.LastEndpoint}
}
return b
}
func (s *Server) handlePeers(w http.ResponseWriter, r *http.Request) {
peers := s.eng.Peers()
out := make([]peerBody, 0, len(peers))
for _, p := range peers {
out = append(out, s.toPeerBody(p))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handlePeer(w http.ResponseWriter, r *http.Request) {
p, err := s.eng.Peer(r.PathValue("id"))
if err != nil {
engineError(w, err)
return
}
writeJSON(w, http.StatusOK, s.toPeerBody(p))
}
type createPeerResponse struct {
peerBody
Config string `json:"config"`
}
func (s *Server) handleCreatePeer(w http.ResponseWriter, r *http.Request) {
var in engine.PeerInput
if !readJSON(w, r, &in) {
return
}
p, err := s.eng.CreatePeer(r.Context(), in)
if err != nil {
engineError(w, err)
return
}
s.audit(r, "peer.created", p.Name, fmt.Sprintf("%s %s", p.ID, p.IPv4))
writeJSON(w, http.StatusCreated, createPeerResponse{peerBody: s.toPeerBody(p), Config: s.eng.ClientConfig(p)})
}
func (s *Server) handleUpdatePeer(w http.ResponseWriter, r *http.Request) {
var in engine.PeerInput
if !readJSON(w, r, &in) {
return
}
p, err := s.eng.UpdatePeer(r.Context(), r.PathValue("id"), in)
if err != nil {
engineError(w, err)
return
}
s.audit(r, "peer.updated", p.Name, p.ID)
writeJSON(w, http.StatusOK, s.toPeerBody(p))
}
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
p, err := s.eng.Peer(id)
if err != nil {
engineError(w, err)
return
}
if err := s.eng.DeletePeer(r.Context(), id); err != nil {
engineError(w, err)
return
}
s.audit(r, "peer.deleted", p.Name, id)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleEnablePeer(w http.ResponseWriter, r *http.Request) {
p, err := s.eng.SetEnabled(r.Context(), r.PathValue("id"), true)
if err != nil {
engineError(w, err)
return
}
s.audit(r, "peer.enabled", p.Name, p.ID)
writeJSON(w, http.StatusOK, s.toPeerBody(p))
}
// handleDisablePeer is "disconnect": the peer leaves the interface and its
// session dies with it.
func (s *Server) handleDisablePeer(w http.ResponseWriter, r *http.Request) {
p, err := s.eng.SetEnabled(r.Context(), r.PathValue("id"), false)
if err != nil {
engineError(w, err)
return
}
s.audit(r, "peer.disabled", p.Name, p.ID)
writeJSON(w, http.StatusOK, s.toPeerBody(p))
}
func (s *Server) handleResetPeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
p, err := s.eng.Peer(id)
if err != nil {
engineError(w, err)
return
}
if err := s.eng.ResetSession(r.Context(), id); err != nil {
engineError(w, err)
return
}
s.audit(r, "peer.session_reset", p.Name, id)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleRotatePeer(w http.ResponseWriter, r *http.Request) {
p, err := s.eng.RotateKeys(r.Context(), r.PathValue("id"))
if err != nil {
engineError(w, err)
return
}
s.audit(r, "peer.keys_rotated", p.Name, p.ID)
writeJSON(w, http.StatusOK, createPeerResponse{peerBody: s.toPeerBody(p), Config: s.eng.ClientConfig(p)})
}
func safeFilename(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
case r == ' ' || r == '.':
b.WriteByte('-')
}
}
out := b.String()
if out == "" {
out = "wgx"
}
if len(out) > 15 {
// wg-quick derives the interface name from the file name and caps it
// at 15 characters.
out = out[:15]
}
return out
}
func (s *Server) handlePeerConfig(w http.ResponseWriter, r *http.Request) {
p, err := s.eng.Peer(r.PathValue("id"))
if err != nil {
engineError(w, err)
return
}
cfg := s.eng.ClientConfig(p)
if r.URL.Query().Get("download") != "" {
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.conf"`, safeFilename(p.Name)))
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
s.audit(r, "peer.config_viewed", p.Name, p.ID)
_, _ = w.Write([]byte(cfg))
}
func (s *Server) handlePeerQR(w http.ResponseWriter, r *http.Request) {
p, err := s.eng.Peer(r.PathValue("id"))
if err != nil {
engineError(w, err)
return
}
size, _ := strconv.Atoi(r.URL.Query().Get("size"))
png, err := s.eng.QRCode(p, size)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(png)
}
func sinceParam(r *http.Request) time.Time {
switch r.URL.Query().Get("range") {
case "1h":
return time.Now().Add(-time.Hour)
case "7d":
return time.Now().Add(-7 * 24 * time.Hour)
case "30d":
return time.Now().Add(-30 * 24 * time.Hour)
case "90d":
return time.Now().Add(-90 * 24 * time.Hour)
default:
return time.Now().Add(-24 * time.Hour)
}
}
func (s *Server) handlePeerUsage(w http.ResponseWriter, r *http.Request) {
pts, err := s.eng.Usage(r.Context(), r.PathValue("id"), sinceParam(r))
if err != nil {
engineError(w, err)
return
}
writeJSON(w, http.StatusOK, pts)
}
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
pts, err := s.eng.Usage(r.Context(), "", sinceParam(r))
if err != nil {
engineError(w, err)
return
}
writeJSON(w, http.StatusOK, pts)
}
func (s *Server) handleUsageByPeer(w http.ResponseWriter, r *http.Request) {
u, err := s.eng.UsageByPeer(r.Context(), sinceParam(r))
if err != nil {
engineError(w, err)
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.eng.Settings())
}
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
var in engine.Settings
if !readJSON(w, r, &in) {
return
}
if err := s.eng.UpdateSettings(r.Context(), in); err != nil {
if strings.Contains(err.Error(), "firewall") {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.audit(r, "settings.updated", "", "")
writeJSON(w, http.StatusOK, s.eng.Settings())
}
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 1000 {
limit = 200
}
entries, err := s.eng.Store().ListAudit(r.Context(), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, entries)
}
// handleEvents streams status snapshots and change notices as SSE.
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
// Send the current picture at once rather than waiting for the next poll.
snap := s.eng.Snapshot()
if !snap.At.IsZero() {
s.eng.Hub().Publish("status", snap)
}
ch, leave := s.eng.Hub().Subscribe()
defer leave()
fmt.Fprintf(w, "retry: 3000\n\n")
flusher.Flush()
keep := time.NewTicker(25 * time.Second)
defer keep.Stop()
for {
select {
case <-r.Context().Done():
return
case <-keep.C:
fmt.Fprintf(w, ": keepalive\n\n")
flusher.Flush()
case ev := <-ch:
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Name, ev.Data)
flusher.Flush()
}
}
}
// handleMetrics exposes Prometheus metrics, protected by a bearer token or
// an admin session.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
allowed := false
if s.cfg.MetricsToken != "" {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if subtle.ConstantTimeCompare([]byte(tok), []byte(s.cfg.MetricsToken)) == 1 {
allowed = true
}
}
if !allowed {
if u, sess := s.loadSession(r); u != nil && !sess.TOTPPending {
allowed = true
}
}
if !allowed {
writeError(w, http.StatusUnauthorized, "metrics require a bearer token or a session")
return
}
snap := s.eng.Snapshot()
peers := s.eng.Peers()
names := map[string]string{}
for _, p := range peers {
names[p.ID] = p.Name
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
var b strings.Builder
fmt.Fprintf(&b, "# HELP wgx_peers Number of configured peers.\n# TYPE wgx_peers gauge\nwgx_peers %d\n", snap.Totals.Peers)
fmt.Fprintf(&b, "# HELP wgx_peers_connected Peers with a recent handshake.\n# TYPE wgx_peers_connected gauge\nwgx_peers_connected %d\n", snap.Totals.Connected)
fmt.Fprintf(&b, "# HELP wgx_receive_bytes_total Bytes received from peers.\n# TYPE wgx_receive_bytes_total counter\n")
for id, l := range snap.Peers {
fmt.Fprintf(&b, "wgx_receive_bytes_total{peer=%q,name=%q} %d\n", id, names[id], l.Rx)
}
fmt.Fprintf(&b, "# HELP wgx_transmit_bytes_total Bytes sent to peers.\n# TYPE wgx_transmit_bytes_total counter\n")
for id, l := range snap.Peers {
fmt.Fprintf(&b, "wgx_transmit_bytes_total{peer=%q,name=%q} %d\n", id, names[id], l.Tx)
}
fmt.Fprintf(&b, "# HELP wgx_peer_connected Whether the peer has a recent handshake.\n# TYPE wgx_peer_connected gauge\n")
for id, l := range snap.Peers {
v := 0
if l.Connected {
v = 1
}
fmt.Fprintf(&b, "wgx_peer_connected{peer=%q,name=%q} %d\n", id, names[id], v)
}
fmt.Fprintf(&b, "# HELP wgx_peer_last_handshake_seconds Unix time of the last handshake.\n# TYPE wgx_peer_last_handshake_seconds gauge\n")
for id, l := range snap.Peers {
if !l.LastHandshake.IsZero() {
fmt.Fprintf(&b, "wgx_peer_last_handshake_seconds{peer=%q,name=%q} %d\n", id, names[id], l.LastHandshake.Unix())
}
}
_, _ = w.Write([]byte(b.String()))
}
+745
View File
@@ -0,0 +1,745 @@
package server
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/skip2/go-qrcode"
"github.com/Coffey-Labs/WGX/internal/auth"
"github.com/Coffey-Labs/WGX/internal/store"
)
const cookieName = "wgx_session"
type ctxKey int
const (
ctxUser ctxKey = iota
ctxSession
)
// userOf returns the authenticated user for a request.
func userOf(r *http.Request) *store.User {
u, _ := r.Context().Value(ctxUser).(*store.User)
return u
}
func sessionOf(r *http.Request) *store.Session {
s, _ := r.Context().Value(ctxSession).(*store.Session)
return s
}
func (s *Server) setCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies,
SameSite: http.SameSiteStrictMode,
Expires: expires,
})
}
func (s *Server) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: -1})
}
// loadSession resolves the cookie to a user, if the session is valid.
func (s *Server) loadSession(r *http.Request) (*store.User, *store.Session) {
c, err := r.Cookie(cookieName)
if err != nil || c.Value == "" {
return nil, nil
}
ctx := r.Context()
sess, err := s.eng.Store().SessionByHash(ctx, auth.HashToken(c.Value))
if err != nil {
return nil, nil
}
now := time.Now()
if now.After(sess.ExpiresAt) || now.Sub(sess.CreatedAt) > s.cfg.SessionMax {
_ = s.eng.Store().DeleteSession(ctx, sess.TokenHash)
return nil, nil
}
u, err := s.eng.Store().UserByID(ctx, sess.UserID)
if err != nil {
return nil, nil
}
// Slide the idle expiry, but not on every request: once a minute is
// plenty and keeps the write load off SQLite.
if now.Sub(sess.LastSeenAt) > time.Minute {
_ = s.eng.Store().TouchSession(ctx, sess.TokenHash, now.Add(s.cfg.SessionIdle))
}
return u, sess
}
// authed requires a fully authenticated session.
func (s *Server) authed(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
writeError(w, http.StatusForbidden, "cross-site request refused")
return
}
u, sess := s.loadSession(r)
if u == nil || sess.TOTPPending {
writeError(w, http.StatusUnauthorized, "not signed in")
return
}
ctx := context.WithValue(r.Context(), ctxUser, u)
ctx = context.WithValue(ctx, ctxSession, sess)
next(w, r.WithContext(ctx))
})
}
// admin additionally requires the admin role.
func (s *Server) admin(next http.HandlerFunc) http.Handler {
return s.authed(func(w http.ResponseWriter, r *http.Request) {
if userOf(r).Role != "admin" {
writeError(w, http.StatusForbidden, "administrator role required")
return
}
next(w, r)
})
}
func (s *Server) audit(r *http.Request, action, target, detail string) {
actor := "-"
if u := userOf(r); u != nil {
actor = u.Username
}
if err := s.eng.Store().Audit(r.Context(), store.AuditEntry{Actor: actor, Action: action, Target: target, Detail: detail, IP: s.clientIP(r)}); err != nil {
s.log.Warn("audit write failed", "error", err)
}
s.log.Info("audit", "actor", actor, "action", action, "target", target, "detail", detail, "ip", s.clientIP(r))
}
// --- setup -----------------------------------------------------------------
type setupStatus struct {
NeedsSetup bool `json:"needsSetup"`
}
func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
n, err := s.eng.Store().CountUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, setupStatus{NeedsSetup: n == 0})
}
type setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
EndpointHost string `json:"endpointHost"`
}
func validUsername(u string) bool {
if len(u) < 2 || len(u) > 32 {
return false
}
for _, r := range u {
if !(r == '.' || r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
// handleSetup creates the first administrator. It only works while there
// are no users at all, so a running server cannot be taken over by it.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
writeError(w, http.StatusForbidden, "cross-site request refused")
return
}
ctx := r.Context()
n, err := s.eng.Store().CountUsers(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if n > 0 {
writeError(w, http.StatusConflict, "setup has already been completed")
return
}
var req setupRequest
if !readJSON(w, r, &req) {
return
}
req.Username = strings.TrimSpace(req.Username)
if !validUsername(req.Username) {
writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore")
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
settings := s.eng.Settings()
if host := strings.TrimSpace(req.EndpointHost); host != "" {
settings.EndpointHost = host
}
if err := settings.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
u, err := s.eng.Store().CreateUser(ctx, req.Username, hash, "admin")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.eng.UpdateSettings(ctx, settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.startSession(w, r, u, false); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
s.audit(r, "setup", u.Username, "first administrator created")
writeJSON(w, http.StatusCreated, s.meBody(ctx, u))
}
// --- login -----------------------------------------------------------------
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct {
TOTPRequired bool `json:"totpRequired"`
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User, totpPending bool) error {
token, hash, err := auth.NewToken()
if err != nil {
return err
}
now := time.Now()
expires := now.Add(s.cfg.SessionIdle)
if totpPending {
expires = now.Add(10 * time.Minute)
}
ua := r.UserAgent()
if len(ua) > 200 {
ua = ua[:200]
}
if err := s.eng.Store().CreateSession(r.Context(), store.Session{
TokenHash: hash, UserID: u.ID, CreatedAt: now, LastSeenAt: now, ExpiresAt: expires,
IP: s.clientIP(r), UserAgent: ua, TOTPPending: totpPending,
}); err != nil {
return err
}
s.setCookie(w, token, now.Add(s.cfg.SessionMax))
return nil
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
writeError(w, http.StatusForbidden, "cross-site request refused")
return
}
ip := s.clientIP(r)
if ok, wait := s.ipLimit.Allowed(ip); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
return
}
var req loginRequest
if !readJSON(w, r, &req) {
return
}
req.Username = strings.TrimSpace(req.Username)
if ok, wait := s.usrLimit.Allowed(strings.ToLower(req.Username)); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
return
}
ctx := r.Context()
u, err := s.eng.Store().UserByName(ctx, req.Username)
if err != nil {
auth.EqualiseTiming()
s.ipLimit.Fail(ip)
s.usrLimit.Fail(strings.ToLower(req.Username))
s.log.Warn("login failed", "user", req.Username, "ip", ip)
writeError(w, http.StatusUnauthorized, "wrong username or password")
return
}
if !auth.VerifyPassword(u.PasswordHash, req.Password) {
s.ipLimit.Fail(ip)
s.usrLimit.Fail(strings.ToLower(req.Username))
s.log.Warn("login failed", "user", req.Username, "ip", ip)
_ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.failed", IP: ip})
writeError(w, http.StatusUnauthorized, "wrong username or password")
return
}
s.ipLimit.Reset(ip)
s.usrLimit.Reset(strings.ToLower(req.Username))
if err := s.startSession(w, r, u, u.TOTPEnabled); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if u.TOTPEnabled {
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
return
}
_ = s.eng.Store().TouchLogin(ctx, u.ID)
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
s.audit(r, "login", u.Username, "")
writeJSON(w, http.StatusOK, loginResponse{})
}
type totpRequest struct {
Code string `json:"code"`
}
// handleLoginTOTP completes a login that is waiting on a second factor.
// A recovery code is accepted in place of a TOTP code.
func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
writeError(w, http.StatusForbidden, "cross-site request refused")
return
}
u, sess := s.loadSession(r)
if u == nil || !sess.TOTPPending {
writeError(w, http.StatusUnauthorized, "no login in progress")
return
}
ip := s.clientIP(r)
if ok, wait := s.ipLimit.Allowed(ip); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
return
}
var req totpRequest
if !readJSON(w, r, &req) {
return
}
ctx := r.Context()
ok := auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now())
usedRecovery := false
if !ok {
used, err := s.eng.Store().UseRecoveryCode(ctx, u.ID, auth.HashToken(auth.NormaliseRecoveryCode(req.Code)))
if err == nil && used {
ok, usedRecovery = true, true
}
}
if !ok {
s.ipLimit.Fail(ip)
_ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.totp_failed", IP: ip})
writeError(w, http.StatusUnauthorized, "wrong code")
return
}
s.ipLimit.Reset(ip)
if err := s.eng.Store().ClearTOTPPending(ctx, sess.TokenHash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.eng.Store().TouchSession(ctx, sess.TokenHash, time.Now().Add(s.cfg.SessionIdle))
_ = s.eng.Store().TouchLogin(ctx, u.ID)
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
detail := ""
if usedRecovery {
detail = "recovery code used"
}
s.audit(r, "login", u.Username, detail)
writeJSON(w, http.StatusOK, loginResponse{})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if sess := sessionOf(r); sess != nil {
_ = s.eng.Store().DeleteSession(r.Context(), sess.TokenHash)
}
s.clearCookie(w)
s.audit(r, "logout", userOf(r).Username, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// --- account ---------------------------------------------------------------
type meBody struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
TOTPEnabled bool `json:"totpEnabled"`
RecoveryCodes int `json:"recoveryCodesLeft"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt time.Time `json:"lastLoginAt,omitempty"`
}
func (s *Server) meBody(ctx context.Context, u *store.User) meBody {
left, _ := s.eng.Store().RecoveryCodesLeft(ctx, u.ID)
return meBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, RecoveryCodes: left, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.meBody(r.Context(), userOf(r)))
}
type changePasswordRequest struct {
Current string `json:"current"`
New string `json:"new"`
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
var req changePasswordRequest
if !readJSON(w, r, &req) {
return
}
u := userOf(r)
if !auth.VerifyPassword(u.PasswordHash, req.Current) {
writeError(w, http.StatusForbidden, "current password is wrong")
return
}
if err := auth.ValidatePassword(req.New); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.New)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.eng.Store().SetPassword(r.Context(), u.ID, hash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Every other browser is signed out; this one keeps its session.
sess := sessionOf(r)
_ = s.eng.Store().DeleteUserSessions(r.Context(), u.ID)
if err := s.startSession(w, r, u, false); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = sess
s.audit(r, "password.changed", u.Username, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
type totpSetupResponse struct {
Secret string `json:"secret"`
URI string `json:"uri"`
}
// handleTOTPSetup issues a pending secret; it becomes active on confirm.
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
u := userOf(r)
if u.TOTPEnabled {
writeError(w, http.StatusConflict, "two-factor authentication is already on")
return
}
secret, err := auth.NewTOTPSecret()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.eng.Store().SetTOTP(r.Context(), u.ID, secret, false); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: auth.TOTPURI("WGX", u.Username, secret)})
}
// handleTOTPQR renders the pending secret's otpauth URI as a QR code. Only a
// secret that is not yet active is shown: an enabled one must never leave
// the server again.
func (s *Server) handleTOTPQR(w http.ResponseWriter, r *http.Request) {
u := userOf(r)
if u.TOTPEnabled || u.TOTPSecret == "" {
writeError(w, http.StatusNotFound, "no two-factor setup in progress")
return
}
png, err := qrcode.Encode(auth.TOTPURI("WGX", u.Username, u.TOTPSecret), qrcode.Medium, 256)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(png)
}
type totpConfirmResponse struct {
RecoveryCodes []string `json:"recoveryCodes"`
}
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
var req totpRequest
if !readJSON(w, r, &req) {
return
}
u := userOf(r)
if u.TOTPEnabled || u.TOTPSecret == "" {
writeError(w, http.StatusConflict, "start two-factor setup first")
return
}
if !auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now()) {
writeError(w, http.StatusBadRequest, "wrong code; check the time on your device")
return
}
codes, hashes, err := auth.NewRecoveryCodes(8)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ctx := r.Context()
if err := s.eng.Store().SetTOTP(ctx, u.ID, u.TOTPSecret, true); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, hashes); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "totp.enabled", u.Username, "")
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
}
type totpDisableRequest struct {
Password string `json:"password"`
}
func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
var req totpDisableRequest
if !readJSON(w, r, &req) {
return
}
u := userOf(r)
if !auth.VerifyPassword(u.PasswordHash, req.Password) {
writeError(w, http.StatusForbidden, "password is wrong")
return
}
ctx := r.Context()
if err := s.eng.Store().SetTOTP(ctx, u.ID, "", false); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, nil)
s.audit(r, "totp.disabled", u.Username, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
type sessionBody struct {
Current bool `json:"current"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt"`
IP string `json:"ip"`
UserAgent string `json:"userAgent"`
}
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
u := userOf(r)
cur := sessionOf(r)
rows, err := s.eng.Store().DB().QueryContext(r.Context(), `SELECT token_hash, created_at, last_seen_at, ip, user_agent FROM sessions WHERE user_id = ? AND totp_pending = 0 ORDER BY last_seen_at DESC`, u.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []sessionBody{}
for rows.Next() {
var hash, ip, ua string
var created, seen int64
if err := rows.Scan(&hash, &created, &seen, &ip, &ua); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out = append(out, sessionBody{Current: hash == cur.TokenHash, CreatedAt: time.Unix(created, 0), LastSeenAt: time.Unix(seen, 0), IP: ip, UserAgent: ua})
}
writeJSON(w, http.StatusOK, out)
}
// handleRevokeSessions signs the user out everywhere but here.
func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
u := userOf(r)
cur := sessionOf(r)
if _, err := s.eng.Store().DB().ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, u.ID, cur.TokenHash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "sessions.revoked", u.Username, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// --- users -----------------------------------------------------------------
type userBody struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
TOTPEnabled bool `json:"totpEnabled"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt time.Time `json:"lastLoginAt,omitempty"`
}
func toUserBody(u *store.User) userBody {
return userBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
}
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.eng.Store().ListUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]userBody, 0, len(users))
for _, u := range users {
out = append(out, toUserBody(u))
}
writeJSON(w, http.StatusOK, out)
}
type userRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
func validRole(role string) bool { return role == "admin" || role == "viewer" }
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req userRequest
if !readJSON(w, r, &req) {
return
}
req.Username = strings.TrimSpace(req.Username)
if !validUsername(req.Username) {
writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore")
return
}
if !validRole(req.Role) {
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
u, err := s.eng.Store().CreateUser(r.Context(), req.Username, hash, req.Role)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
writeError(w, http.StatusConflict, "that username is taken")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "user.created", u.Username, "role "+u.Role)
writeJSON(w, http.StatusCreated, toUserBody(u))
}
type userUpdateRequest struct {
Role string `json:"role,omitempty"`
Password string `json:"password,omitempty"`
ResetTOTP bool `json:"resetTotp,omitempty"`
}
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad user id")
return
}
var req userUpdateRequest
if !readJSON(w, r, &req) {
return
}
ctx := r.Context()
target, err := s.eng.Store().UserByID(ctx, id)
if err != nil {
writeError(w, http.StatusNotFound, "no such user")
return
}
var changes []string
if req.Role != "" && req.Role != target.Role {
if !validRole(req.Role) {
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
return
}
if target.ID == userOf(r).ID {
writeError(w, http.StatusBadRequest, "you cannot change your own role")
return
}
if err := s.eng.Store().SetRole(ctx, id, req.Role); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
changes = append(changes, "role "+req.Role)
}
if req.Password != "" {
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.eng.Store().SetPassword(ctx, id, hash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.eng.Store().DeleteUserSessions(ctx, id)
changes = append(changes, "password reset")
}
if req.ResetTOTP && target.TOTPEnabled {
if err := s.eng.Store().SetTOTP(ctx, id, "", false); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.eng.Store().ReplaceRecoveryCodes(ctx, id, nil)
changes = append(changes, "two-factor reset")
}
if len(changes) > 0 {
s.audit(r, "user.updated", target.Username, strings.Join(changes, ", "))
}
u, _ := s.eng.Store().UserByID(ctx, id)
writeJSON(w, http.StatusOK, toUserBody(u))
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad user id")
return
}
if id == userOf(r).ID {
writeError(w, http.StatusBadRequest, "you cannot delete yourself")
return
}
ctx := r.Context()
target, err := s.eng.Store().UserByID(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "no such user")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.eng.Store().DeleteUser(ctx, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "user.deleted", target.Username, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+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())
}
}
+265
View File
@@ -0,0 +1,265 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"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/store"
"github.com/Coffey-Labs/WGX/internal/wg"
)
type client struct {
t *testing.T
srv *httptest.Server
c *http.Client
}
func newClient(t *testing.T) (*client, *engine.Engine) {
t.Helper()
cfg := &config.Config{
DBPath: ":memory:", Backend: "mock", Iface: "wg0", ListenPort: 51820,
Subnet4: netip.MustParsePrefix("10.8.0.0/24"), HTTP: "127.0.0.1:0",
SessionIdle: time.Hour, SessionMax: 24 * time.Hour, TrafficRetention: time.Hour, PollInterval: time.Hour,
InitialEndpoint: "vpn.example.com", InitialDNS: "1.1.1.1", MetricsToken: "metrics-secret",
}
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
eng := engine.New(cfg, st, wg.NewMock("wg0", false), log)
if err := eng.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = eng.Stop(context.Background()) })
s := New(cfg, eng, log)
srv := httptest.NewServer(s.Handler())
t.Cleanup(srv.Close)
jar, _ := cookiejar.New(nil)
return &client{t: t, srv: srv, c: &http.Client{Jar: jar}}, eng
}
func (c *client) do(method, path string, body any, headers ...string) (*http.Response, []byte) {
c.t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, c.srv.URL+path, rd)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
res, err := c.c.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
return res, out
}
func (c *client) expect(method, path string, body any, status int) []byte {
c.t.Helper()
res, out := c.do(method, path, body)
if res.StatusCode != status {
c.t.Fatalf("%s %s: got %d, want %d: %s", method, path, res.StatusCode, status, out)
}
return out
}
func TestSetupLoginAndPeers(t *testing.T) {
c, _ := newClient(t)
// Before setup: nothing works, setup is announced.
out := c.expect("GET", "/api/setup", nil, 200)
if !strings.Contains(string(out), `"needsSetup":true`) {
t.Fatal(out)
}
c.expect("GET", "/api/peers", nil, 401)
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "short"}, 400)
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
c.expect("POST", "/api/setup", map[string]string{"username": "x", "password": "a-long-enough-password"}, 409)
// Setup signed us in.
out = c.expect("GET", "/api/auth/me", nil, 200)
if !strings.Contains(string(out), `"username":"admin"`) || !strings.Contains(string(out), `"role":"admin"`) {
t.Fatal(string(out))
}
c.expect("POST", "/api/auth/logout", nil, 200)
c.expect("GET", "/api/auth/me", nil, 401)
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "wrong-password-here"}, 401)
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
// Cross-site state change refused.
res, _ := c.do("POST", "/api/peers", map[string]string{"name": "x"}, "Sec-Fetch-Site", "cross-site")
if res.StatusCode != 403 {
t.Fatalf("cross-site request got %d", res.StatusCode)
}
out = c.expect("POST", "/api/peers", map[string]any{"name": "Laptop", "clientRoutes": "", "dns": "", "keepalive": nil, "mtu": nil, "expiresAt": nil, "notes": ""}, 201)
var created struct {
ID string `json:"id"`
IPv4 string `json:"ipv4"`
Config string `json:"config"`
}
_ = json.Unmarshal(out, &created)
if created.IPv4 != "10.8.0.2" || !strings.Contains(created.Config, "Endpoint = vpn.test:51820") {
t.Fatalf("%+v", created)
}
if strings.Contains(string(out), `"privateKey"`) {
t.Fatal("private key leaked in peer body")
}
out = c.expect("GET", "/api/peers", nil, 200)
if !strings.Contains(string(out), `"name":"Laptop"`) {
t.Fatal(string(out))
}
res, out = c.do("GET", "/api/peers/"+created.ID+"/config?download=1", nil)
if res.StatusCode != 200 || !strings.Contains(res.Header.Get("Content-Disposition"), `Laptop.conf`) || !strings.Contains(string(out), "[Interface]") {
t.Fatalf("config download: %d %s", res.StatusCode, res.Header)
}
res, out = c.do("GET", "/api/peers/"+created.ID+"/qr.png", nil)
if res.StatusCode != 200 || res.Header.Get("Content-Type") != "image/png" || !bytes.HasPrefix(out, []byte("\x89PNG")) {
t.Fatalf("qr: %d %s", res.StatusCode, res.Header.Get("Content-Type"))
}
c.expect("POST", "/api/peers/"+created.ID+"/disable", nil, 200)
out = c.expect("GET", "/api/peers/"+created.ID, nil, 200)
if !strings.Contains(string(out), `"enabled":false`) {
t.Fatal(string(out))
}
c.expect("POST", "/api/peers/"+created.ID+"/enable", nil, 200)
c.expect("POST", "/api/peers/"+created.ID+"/reset", nil, 200)
c.expect("POST", "/api/peers/"+created.ID+"/rotate", nil, 200)
c.expect("PUT", "/api/peers/"+created.ID, map[string]any{"name": "Laptop 2", "clientRoutes": "10.8.0.0/24", "dns": "9.9.9.9", "keepalive": 15, "mtu": 1380, "expiresAt": nil, "notes": "n"}, 200)
c.expect("GET", "/api/peers/"+created.ID+"/usage?range=24h", nil, 200)
c.expect("GET", "/api/usage?range=7d", nil, 200)
c.expect("GET", "/api/status", nil, 200)
c.expect("GET", "/api/audit", nil, 200)
c.expect("DELETE", "/api/peers/"+created.ID, nil, 200)
c.expect("GET", "/api/peers/"+created.ID, nil, 404)
// Settings round trip.
out = c.expect("GET", "/api/settings", nil, 200)
var s engine.Settings
_ = json.Unmarshal(out, &s)
s.MTU = 1400
c.expect("PUT", "/api/settings", s, 200)
s.MTU = 10
c.expect("PUT", "/api/settings", s, 400)
// Metrics: token or session.
res, _ = c.do("GET", "/metrics", nil)
if res.StatusCode != 200 {
t.Fatalf("metrics with session: %d", res.StatusCode)
}
anon := &http.Client{}
req, _ := http.NewRequest("GET", c.srv.URL+"/metrics", nil)
if r, _ := anon.Do(req); r.StatusCode != 401 {
t.Fatalf("anonymous metrics: %d", r.StatusCode)
}
req.Header.Set("Authorization", "Bearer metrics-secret")
r, _ := anon.Do(req)
b, _ := io.ReadAll(r.Body)
if r.StatusCode != 200 || !strings.Contains(string(b), "wgx_peers ") {
t.Fatalf("token metrics: %d %s", r.StatusCode, b)
}
}
func TestViewerRoleAndUsers(t *testing.T) {
c, _ := newClient(t)
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
c.expect("POST", "/api/users", map[string]string{"username": "eve", "password": "another-long-password", "role": "viewer"}, 201)
c.expect("POST", "/api/users", map[string]string{"username": "eve", "password": "another-long-password", "role": "viewer"}, 409)
c.expect("POST", "/api/auth/logout", nil, 200)
c.expect("POST", "/api/auth/login", map[string]string{"username": "eve", "password": "another-long-password"}, 200)
c.expect("GET", "/api/peers", nil, 200)
c.expect("POST", "/api/peers", map[string]any{"name": "x", "clientRoutes": "", "dns": "", "keepalive": nil, "mtu": nil, "expiresAt": nil, "notes": ""}, 403)
c.expect("GET", "/api/users", nil, 200)
c.expect("DELETE", "/api/users/1", nil, 403)
}
func TestTOTPFlow(t *testing.T) {
c, _ := newClient(t)
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
out := c.expect("POST", "/api/auth/totp/setup", nil, 200)
var setup struct{ Secret string }
_ = json.Unmarshal(out, &setup)
c.expect("GET", "/api/auth/totp/qr.png", nil, 200)
c.expect("POST", "/api/auth/totp/confirm", map[string]string{"code": "000000"}, 400)
code, _ := auth.TOTPNow(setup.Secret, time.Now())
out = c.expect("POST", "/api/auth/totp/confirm", map[string]string{"code": code}, 200)
var conf struct{ RecoveryCodes []string }
_ = json.Unmarshal(out, &conf)
if len(conf.RecoveryCodes) != 8 {
t.Fatal("no recovery codes")
}
c.expect("POST", "/api/auth/logout", nil, 200)
// Login now stops half way.
out = c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
if !strings.Contains(string(out), `"totpRequired":true`) {
t.Fatal(string(out))
}
c.expect("GET", "/api/peers", nil, 401)
c.expect("POST", "/api/auth/totp", map[string]string{"code": "123456"}, 401)
code, _ = auth.TOTPNow(setup.Secret, time.Now())
c.expect("POST", "/api/auth/totp", map[string]string{"code": code}, 200)
c.expect("GET", "/api/peers", nil, 200)
// A recovery code works once.
c.expect("POST", "/api/auth/logout", nil, 200)
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
c.expect("POST", "/api/auth/totp", map[string]string{"code": conf.RecoveryCodes[0]}, 200)
c.expect("POST", "/api/auth/logout", nil, 200)
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
c.expect("POST", "/api/auth/totp", map[string]string{"code": conf.RecoveryCodes[0]}, 401)
}
func TestLoginRateLimit(t *testing.T) {
c, _ := newClient(t)
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
c.expect("POST", "/api/auth/logout", nil, 200)
var last int
for i := 0; i < 10; i++ {
res, _ := c.do("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "wrong-password-here"})
last = res.StatusCode
}
if last != 429 {
t.Fatalf("expected 429 after repeated failures, got %d", last)
}
}
func TestSecurityHeadersAndSPA(t *testing.T) {
c, _ := newClient(t)
res, _ := c.do("GET", "/api/health", nil)
for _, h := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy"} {
if res.Header.Get(h) == "" {
t.Errorf("missing %s", h)
}
}
if res.Header.Get("Cache-Control") != "no-store" {
t.Error("api responses must be no-store")
}
res, _ = c.do("GET", "/api/nope", nil)
if res.StatusCode != 404 {
t.Errorf("unknown api path: %d", res.StatusCode)
}
}
View File
+20
View File
@@ -0,0 +1,20 @@
// Package static carries the built admin UI inside the binary. `npm run
// build` in web/ writes into dist/; the Go build embeds whatever is there.
package static
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// FS returns the built UI rooted at dist/.
func FS() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
panic(err)
}
return sub
}
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
// loadCertificate returns the configured certificate, generating and
// persisting a self-signed one when asked to.
func (s *Server) loadCertificate() (tls.Certificate, error) {
if s.cfg.TLSCert != "" {
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("load TLS certificate: %w", err)
}
return cert, nil
}
certPath := filepath.Join(s.cfg.DataDir, "tls.crt")
keyPath := filepath.Join(s.cfg.DataDir, "tls.key")
if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil && time.Now().Before(leaf.NotAfter.Add(-30*24*time.Hour)) {
return cert, nil
}
}
s.log.Info("generating a self-signed TLS certificate", "path", certPath)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, err
}
host := s.eng.Settings().EndpointHost
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "WGX", Organization: []string{"WGX"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(3 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
}
if host != "" {
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
} else {
tmpl.DNSNames = append(tmpl.DNSNames, host)
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, err
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return tls.Certificate{}, err
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return tls.Certificate{}, err
}
return tls.X509KeyPair(certPEM, keyPEM)
}