Files
2026-07-18 10:02:43 +03:00

584 lines
19 KiB
Go

package main
import (
"crypto/subtle"
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
//go:embed webui/* webui_glass/*
var webAssets embed.FS
// webThemes maps a user-facing theme name to its embedded asset directory. Both
// designs share the same REST API and JS logic; only the static shell differs,
// so a theme is just a different sub-directory of the embedded FS.
var webThemes = map[string]string{
"classic": "webui", // original Modern Dark design (default)
"glass": "webui_glass", // glassmorphism · Slate + Amber
}
// webModes are the accepted colour modes. "dark" (default) forces the dark
// palette; "light" forces light; "auto" follows the viewer's OS preference.
// The mode is stamped onto <html data-theme="…"> at serve time — see
// injectUIMode. glass is a dark-only design and ignores light/auto.
var webModes = map[string]bool{"dark": true, "light": true, "auto": true}
// injectUIMode stamps data-theme="<mode>" onto the <html> tag of the served
// index.html so the stylesheet resolves the palette before first paint. It
// keys off the `<html lang="en"` prefix both theme shells share; if that
// anchor is ever renamed the page still works (it just falls back to CSS
// defaults), so the replace is best-effort.
func injectUIMode(html []byte, mode string) []byte {
const anchor = `<html lang="en"`
attr := fmt.Sprintf(`<html lang="en" data-theme=%q`, mode)
return []byte(strings.Replace(string(html), anchor, attr, 1))
}
// opLock serialises all mutating operations (create/delete/enable/disable/sync
// /server control). The underlying registry + interface state are shared, so we
// process one change at a time to avoid races between concurrent requests.
var opLock sync.Mutex
// runWeb starts the management web server. It is deliberately the only place
// that flips webMode on, so every die() reached from a handler becomes a
// recoverable panic instead of killing the process.
func runWeb(args []string) {
addr := "127.0.0.1:8080"
// Theme precedence: --theme flag > AWG_WEB_THEME env > "classic" default.
theme := os.Getenv("AWG_WEB_THEME")
if theme == "" {
theme = "classic"
}
// Colour-mode precedence: --ui-mode flag > AWG_WEB_MODE env > "dark" default.
// "dark" forces the dark palette regardless of the viewer's OS preference.
uiMode := os.Getenv("AWG_WEB_MODE")
if uiMode == "" {
uiMode = "dark"
}
for i := 0; i < len(args); i++ {
switch args[i] {
case "-addr", "--addr":
if i+1 >= len(args) {
die("--addr requires a value (e.g. 0.0.0.0:8080)")
}
addr = args[i+1]
i++
case "-theme", "--theme":
if i+1 >= len(args) {
die("--theme requires a value (classic or glass)")
}
theme = args[i+1]
i++
case "-ui-mode", "--ui-mode":
if i+1 >= len(args) {
die("--ui-mode requires a value (dark, light or auto)")
}
uiMode = args[i+1]
i++
default:
die("Unknown web option: %s", args[i])
}
}
assetDir, ok := webThemes[theme]
if !ok {
die("Unknown theme %q (choose: classic, glass)", theme)
}
if !webModes[uiMode] {
die("Unknown ui-mode %q (choose: dark, light, auto)", uiMode)
}
webMode = true
// Accumulate traffic totals in the background so counters keep folding into
// the durable store even when no browser is polling.
go statsSampler()
user := os.Getenv("AWG_WEB_USER")
pass := os.Getenv("AWG_WEB_PASS")
authOn := user != "" && pass != ""
mux := http.NewServeMux()
registerAPI(mux)
// Static SPA assets, served from the selected theme's embedded directory.
sub, err := fs.Sub(webAssets, assetDir)
if err != nil {
die("failed to open embedded assets: %v", err)
}
// index.html is served with the colour mode stamped onto <html> so the CSS
// applies it before first paint (no flash, no client JS). All other assets
// go through the plain file server.
rawIndex, err := fs.ReadFile(sub, "index.html")
if err != nil {
die("failed to read embedded index.html: %v", err)
}
indexHTML := injectUIMode(rawIndex, uiMode)
fileSrv := http.FileServer(http.FS(sub))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexHTML)
return
}
fileSrv.ServeHTTP(w, r)
})
handler := basicAuth(authOn, user, pass, mux)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 15 * time.Second,
// No write timeout: install-deps / init-server can run for minutes.
}
fmt.Printf("INFO: AmneziaWG web UI listening on http://%s (theme: %s, ui-mode: %s)\n", addr, theme, uiMode)
if authOn {
fmt.Println("INFO: HTTP Basic auth enabled (AWG_WEB_USER/AWG_WEB_PASS)")
} else {
fmt.Println("WARN: no auth set — bind to 127.0.0.1 or set AWG_WEB_USER/AWG_WEB_PASS")
}
if err := srv.ListenAndServe(); err != nil {
die("web server error: %v", err)
}
}
// statsSampler periodically folds live counters into the persisted totals so
// accumulation continues with no browser open and counter resets are caught
// promptly. Each tick is isolated: a panic (e.g. a die() from loadConfig in web
// mode) is recovered so the background loop can never crash the server.
func statsSampler() {
for range time.Tick(20 * time.Second) {
func() {
defer func() { _ = recover() }()
if !serverInitialized() {
return
}
c := loadConfig()
sampleStats(peerStats(c.Interface))
}()
}
}
// basicAuth optionally guards every request with HTTP Basic credentials.
func basicAuth(on bool, user, pass string, next http.Handler) http.Handler {
if !on {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1
if !ok || !userOK || !passOK {
w.Header().Set("WWW-Authenticate", `Basic realm="awg-profiler"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// ─── routing ─────────────────────────────────────────────────────────────────────
func registerAPI(mux *http.ServeMux) {
mux.HandleFunc("GET /api/setup-status", h(handleSetupStatus))
mux.HandleFunc("GET /api/status", h(handleStatus))
mux.HandleFunc("GET /api/clients", h(handleClients))
mux.HandleFunc("POST /api/clients", h(handleCreate))
mux.HandleFunc("DELETE /api/clients/{id}", h(handleDelete))
mux.HandleFunc("POST /api/clients/{id}/enable", h(handleEnable))
mux.HandleFunc("POST /api/clients/{id}/disable", h(handleDisable))
mux.HandleFunc("POST /api/clients/{id}/comment", h(handleComment))
mux.HandleFunc("POST /api/clients/{id}/stats/reset", h(handleStatsReset))
mux.HandleFunc("GET /api/clients/{id}/config", h(handleConfigDownload))
mux.HandleFunc("GET /api/clients/{id}/qr", h(handleQR))
mux.HandleFunc("POST /api/server/start", h(handleServerStart))
mux.HandleFunc("POST /api/server/stop", h(handleServerStop))
mux.HandleFunc("POST /api/server/restart", h(handleServerRestart))
mux.HandleFunc("POST /api/server/sync", h(handleSync))
mux.HandleFunc("POST /api/install-deps", h(handleInstallDeps))
mux.HandleFunc("POST /api/init-server", h(handleInitServer))
}
// h wraps an API handler with CSRF hardening and panic recovery (turning
// die() into a JSON error response).
func h(fn func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !csrfSafe(r) {
writeErr(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
defer func() {
if rec := recover(); rec != nil {
msg := fmt.Sprintf("%v", rec)
if de, ok := rec.(dieError); ok {
msg = de.msg
}
writeErr(w, http.StatusBadRequest, msg)
}
}()
fn(w, r)
}
}
// csrfSafe blocks cross-site form-triggered state changes. Basic-auth
// credentials are attached to same-origin requests automatically by the
// browser, so without this check a malicious page could submit a blind
// <form> POST/DELETE (forms can only send text/plain,
// application/x-www-form-urlencoded, or multipart/form-data — never
// application/json) and trigger e.g. server-stop or client deletion under
// the logged-in admin's session. GET requests are read-only and exempt.
func csrfSafe(r *http.Request) bool {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
return true
}
return strings.HasPrefix(r.Header.Get("Content-Type"), "application/json")
}
// ─── response helpers ──────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// serverInitialized reports whether init-server has produced a config, without
// dying (used to decide between the dashboard and the setup wizard).
func serverInitialized() bool {
_, err := os.Stat(configFile)
return err == nil
}
// ─── handlers: status / setup ───────────────────────────────────────────────────
func handleSetupStatus(w http.ResponseWriter, r *http.Request) {
st := loadState()
resp := map[string]any{
"deps_installed": st.DepsInstalled,
"deps_installed_at": st.InstalledAt,
"server_initialized": serverInitialized(),
}
writeJSON(w, http.StatusOK, resp)
}
func handleStatus(w http.ResponseWriter, r *http.Request) {
if !serverInitialized() {
writeJSON(w, http.StatusOK, map[string]any{"initialized": false})
return
}
c := loadConfig()
up := awgIfaceUp(c.Interface)
clients := loadRegistry()
active := 0
for _, cl := range clients {
if cl.IsEnabled == "ACTIVE" {
active++
}
}
stats := peerStats(c.Interface)
sampleStats(stats) // keep totals accumulating even on status-only polls
online := 0
for _, s := range stats {
if s.Online() {
online++
}
}
writeJSON(w, http.StatusOK, map[string]any{
"initialized": true,
"interface": c.Interface,
"network": c.Network,
"port": c.Port,
"public_ip": c.PublicIP,
"public_key": c.ServerPub,
"dns": c.DNS,
"mtu": c.MTU,
"interface_up": up,
"total_clients": len(clients),
"active_clients": active,
"online_clients": online,
})
}
// ─── handlers: clients ──────────────────────────────────────────────────────────
// clientOut is the browser-facing client shape. It deliberately omits the
// private and preshared keys — those only ever leave via the .conf download.
type clientOut struct {
ID int64 `json:"id"`
Name string `json:"name"`
IP string `json:"ip"`
PublicKey string `json:"public_key"`
IsEnabled string `json:"is_enabled"`
CreatedAt int64 `json:"created_at"`
Comment string `json:"comment"`
Online bool `json:"online"`
Endpoint string `json:"endpoint"`
LatestHandshake int64 `json:"latest_handshake"`
TransferRx int64 `json:"transfer_rx"`
TransferTx int64 `json:"transfer_tx"`
// Cumulative, restart-surviving traffic accumulated from the stats store,
// plus the mark from which it has been counting. These are what the UI shows.
TotalRx int64 `json:"total_rx"`
TotalTx int64 `json:"total_tx"`
StatsSince int64 `json:"stats_since"`
}
func handleClients(w http.ResponseWriter, r *http.Request) {
c := loadConfig()
initStorage()
clients := loadRegistry()
stats := peerStats(c.Interface)
// Fold this live reading into the persisted totals and get the fresh snapshot.
totals := sampleStats(stats)
out := make([]clientOut, 0, len(clients))
for _, cl := range clients {
o := clientOut{
ID: cl.ID,
Name: cl.Name,
IP: cl.IP,
PublicKey: cl.PublicKey,
IsEnabled: cl.IsEnabled,
CreatedAt: cl.CreatedAt,
Comment: cl.Comment,
}
if s, ok := stats[cl.PublicKey]; ok {
o.Online = s.Online()
o.Endpoint = s.Endpoint
o.LatestHandshake = s.LatestHandshake
o.TransferRx = s.TransferRx
o.TransferTx = s.TransferTx
}
if rec, ok := totals[cl.PublicKey]; ok {
o.TotalRx = rec.TotalRx
o.TotalTx = rec.TotalTx
o.StatsSince = rec.Since
}
out = append(out, o)
}
writeJSON(w, http.StatusOK, out)
}
func handleCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeErr(w, http.StatusBadRequest, "client name required")
return
}
opLock.Lock()
defer opLock.Unlock()
requireBinary("awg")
requireBinary("qrencode")
c := loadConfig()
initStorage()
createClient(c, detectOS(), []string{name})
// Return the freshly created client (highest id with this sanitized name).
clients := loadRegistry()
sanitized := sanitize(name)
var created *Client
for i := range clients {
if clients[i].Name == sanitized {
created = &clients[i]
}
}
if created == nil {
writeErr(w, http.StatusInternalServerError, "client created but not found in registry")
return
}
writeJSON(w, http.StatusCreated, clientOut{
ID: created.ID, Name: created.Name, IP: created.IP,
PublicKey: created.PublicKey, IsEnabled: created.IsEnabled,
CreatedAt: created.CreatedAt, Comment: created.Comment,
})
}
func handleComment(w http.ResponseWriter, r *http.Request) {
var body struct {
Comment string `json:"comment"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
opLock.Lock()
defer opLock.Unlock()
loadConfig()
initStorage()
id := parseID([]string{r.PathValue("id")})
cl := setClientComment(id, strings.TrimSpace(body.Comment))
writeJSON(w, http.StatusOK, map[string]any{"status": "updated", "comment": cl.Comment})
}
// handleStatsReset clears a client's accumulated traffic and rebaselines it to
// the current live counter, so the tally restarts from zero as of now.
func handleStatsReset(w http.ResponseWriter, r *http.Request) {
c := loadConfig()
cl := clientByID(r.PathValue("id")) // dies (→ 400/404) if the id is unknown
// Baseline to the current raw counter so we don't re-add pre-reset bytes. If
// the peer is absent from the dump the interface is down (counters restart
// near zero on the way back up), so a zero baseline is correct.
live := peerStats(c.Interface)[cl.PublicKey]
rec := resetStats(cl.PublicKey, live.TransferRx, live.TransferTx)
writeJSON(w, http.StatusOK, map[string]any{
"status": "reset",
"total_rx": rec.TotalRx,
"total_tx": rec.TotalTx,
"stats_since": rec.Since,
})
}
func handleDelete(w http.ResponseWriter, r *http.Request) {
opLock.Lock()
defer opLock.Unlock()
c := loadConfig()
initStorage()
deleteClient(c, detectOS(), []string{r.PathValue("id")})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func handleEnable(w http.ResponseWriter, r *http.Request) {
opLock.Lock()
defer opLock.Unlock()
c := loadConfig()
initStorage()
enableClient(c, detectOS(), []string{r.PathValue("id")})
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})
}
func handleDisable(w http.ResponseWriter, r *http.Request) {
opLock.Lock()
defer opLock.Unlock()
c := loadConfig()
initStorage()
disableClient(c, detectOS(), []string{r.PathValue("id")})
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
}
// clientByID looks up a client for download endpoints.
func clientByID(idStr string) *Client {
id := parseID([]string{idStr})
clients := loadRegistry()
cl := findClient(clients, id)
if cl == nil {
die("Client with ID %d not found", id)
}
return cl
}
func handleConfigDownload(w http.ResponseWriter, r *http.Request) {
cl := clientByID(r.PathValue("id"))
path := filepath.Join(clientDir, cl.Name+".conf")
data, err := os.ReadFile(path)
if err != nil {
die("config file for %s not found", cl.Name)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.conf"`, cl.Name))
_, _ = w.Write(data)
}
func handleQR(w http.ResponseWriter, r *http.Request) {
cl := clientByID(r.PathValue("id"))
path := filepath.Join(clientDir, cl.Name+".png")
data, err := os.ReadFile(path)
if err != nil {
die("QR image for %s not found", cl.Name)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(data)
}
// ─── handlers: server control ───────────────────────────────────────────────────
func handleServerStart(w http.ResponseWriter, r *http.Request) {
opLock.Lock()
defer opLock.Unlock()
c := loadConfig()
serviceStart(detectOS(), c.Interface)
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
}
func handleServerStop(w http.ResponseWriter, r *http.Request) {
opLock.Lock()
defer opLock.Unlock()
c := loadConfig()
serviceStop(detectOS(), c.Interface)
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
}
func handleServerRestart(w http.ResponseWriter, r *http.Request) {
opLock.Lock()
defer opLock.Unlock()
c := loadConfig()
serviceRestart(detectOS(), c.Interface)
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
}
func handleSync(w http.ResponseWriter, r *http.Request) {
var body struct {
Restart bool `json:"restart"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
opLock.Lock()
defer opLock.Unlock()
c := loadConfig()
syncConfigWeb(c, detectOS(), body.Restart)
writeJSON(w, http.StatusOK, map[string]any{"status": "synced", "restarted": body.Restart})
}
// ─── handlers: setup wizard ─────────────────────────────────────────────────────
func handleInstallDeps(w http.ResponseWriter, r *http.Request) {
opLock.Lock()
defer opLock.Unlock()
installDeps() // logs stream to the server console; panics (recovered) on failure
writeJSON(w, http.StatusOK, map[string]string{"status": "installed"})
}
func handleInitServer(w http.ResponseWriter, r *http.Request) {
var p initParams
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
opLock.Lock()
defer opLock.Unlock()
if serverInitialized() {
writeErr(w, http.StatusConflict, "server already initialized")
return
}
c := initServerWeb(p)
writeJSON(w, http.StatusOK, map[string]any{
"status": "initialized",
"interface": c.Interface,
"public_key": c.ServerPub,
})
}