Files
ihasvpn/internal/server/api.go
T
jcoffey 6c006e1d4d 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.
2026-09-12 19:56:08 -07:00

403 lines
12 KiB
Go

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()))
}