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:
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user