584 lines
19 KiB
Go
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,
|
|
})
|
|
}
|