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 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="" onto the tag of the served // index.html so the stylesheet resolves the palette before first paint. It // keys off the ` 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 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 //
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, }) }