// Copyright (c) 2026, Pim van Pelt package main import ( "context" "crypto/subtle" "encoding/json" "fmt" "io/fs" "log/slog" "net/http" "strings" "time" buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd" ) // adminCreds holds the basic-auth credentials for the /admin/ surface. // Enabled is true when both the user and password env vars were set // and non-empty at startup; when false, /admin/ is hidden entirely // (returns 404) so operators who never intended to expose it don't // see a teasing "unauthorized" response. type adminCreds struct { User string Password string Enabled bool } func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broker, admin adminCreds) { byName := make(map[string]*maglevClient, len(clients)) for _, c := range clients { byName[c.name] = c } mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok\n")) }) mux.HandleFunc("/view/api/version", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, VersionInfo{ Version: buildinfo.Version(), Commit: buildinfo.Commit(), Date: buildinfo.Date(), AdminEnabled: admin.Enabled, }) }) mux.HandleFunc("/view/api/maglevds", func(w http.ResponseWriter, _ *http.Request) { infos := make([]MaglevdInfo, 0, len(clients)) for _, c := range clients { infos = append(infos, c.Info()) } writeJSON(w, infos) }) mux.HandleFunc("/view/api/state", func(w http.ResponseWriter, _ *http.Request) { out := make([]*StateSnapshot, 0, len(clients)) for _, c := range clients { out = append(out, c.Snapshot()) } writeJSON(w, out) }) mux.HandleFunc("/view/api/state/", func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/view/api/state/") c, ok := byName[name] if !ok { http.NotFound(w, r) return } writeJSON(w, c.Snapshot()) }) mux.HandleFunc("/view/api/events", func(w http.ResponseWriter, r *http.Request) { serveSSE(w, r, broker) }) // Static SPA served from the embedded dist fs, mounted under /view/. staticFS, err := fs.Sub(webFS, "web/dist") if err != nil { slog.Error("embed-subfs", "err", err) return } fileServer := http.FileServer(http.FS(staticFS)) mux.Handle("/view/", http.StripPrefix("/view/", fileServer)) // /admin/ serves the same SPA shell behind basic auth when the // credentials are configured. Only the index.html is served here — // all JS, CSS, and assets are referenced via absolute /view/assets/ // URLs baked in by Vite, so they continue to load from the // unauthenticated /view/ tree. Read-only API calls also go to // /view/api/* unchanged. Mutation endpoints live under /admin/api/ // so the same basic-auth middleware covers every writing path. if admin.Enabled { indexBytes, ierr := fs.ReadFile(staticFS, "index.html") if ierr != nil { slog.Error("embed-index", "err", ierr) return } serveIndex := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(indexBytes) }) adminAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleAdminAPI(w, r, byName) }) realm := "maglevd-frontend admin" // Register /admin/api/ before /admin/ so the more specific // pattern wins in net/http's ServeMux. mux.Handle("/admin/api/", basicAuth(realm, admin.User, admin.Password, adminAPI)) mux.Handle("/admin/", basicAuth(realm, admin.User, admin.Password, serveIndex)) } mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.Redirect(w, r, "/view/", http.StatusFound) return } http.NotFound(w, r) }) } // handleAdminAPI dispatches mutation requests under /admin/api/. // // Supported shapes: // // POST /admin/api/{maglevd}/backend/{name}/{pause|resume|enable|disable} // → fresh BackendSnapshot as JSON // // POST /admin/api/{maglevd}/frontend/{fe}/pool/{pool}/backend/{name}/weight // body: {"weight": 0-100, "flush": bool} // → fresh FrontendSnapshot as JSON // // The WatchEvents stream also delivers a backend-transition (and, for // the weight case, no event — since the config mutation doesn't flip // the health state). The POST response is primarily for the // originating SPA to learn about failures and to refresh effective // weights immediately. Errors from the gRPC side are surfaced as // 400 (bad request) or 502 (maglevd returned an error). func handleAdminAPI(w http.ResponseWriter, r *http.Request, byName map[string]*maglevClient) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/admin/api/"), "/") // Peel off the maglevd name (always the first segment). if len(parts) < 2 { http.NotFound(w, r) return } maglevd := parts[0] c, ok := byName[maglevd] if !ok { http.NotFound(w, r) return } rest := parts[1:] switch { // {maglevd}/backend/{name}/{action} case len(rest) == 3 && rest[0] == "backend": handleBackendLifecycle(w, r, c, rest[1], rest[2]) // {maglevd}/frontend/{fe}/pool/{pool}/backend/{name}/weight case len(rest) == 7 && rest[0] == "frontend" && rest[2] == "pool" && rest[4] == "backend" && rest[6] == "weight": handleBackendWeight(w, r, c, rest[1], rest[3], rest[5]) default: http.NotFound(w, r) } } func handleBackendLifecycle(w http.ResponseWriter, r *http.Request, c *maglevClient, name, action string) { switch action { case "pause", "resume", "enable", "disable": default: http.Error(w, fmt.Sprintf("unknown action %q", action), http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() snap, err := c.BackendAction(ctx, name, action) if err != nil { slog.Warn("admin-backend-action", "maglevd", c.name, "backend", name, "action", action, "err", err) http.Error(w, err.Error(), http.StatusBadGateway) return } slog.Info("admin-backend-action", "maglevd", c.name, "backend", name, "action", action, "state", snap.State) writeJSON(w, snap) } type setWeightBody struct { Weight int32 `json:"weight"` Flush bool `json:"flush"` } func handleBackendWeight(w http.ResponseWriter, r *http.Request, c *maglevClient, frontend, pool, backend string) { var body setWeightBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, fmt.Sprintf("bad json: %v", err), http.StatusBadRequest) return } if body.Weight < 0 || body.Weight > 100 { http.Error(w, fmt.Sprintf("weight %d out of range [0, 100]", body.Weight), http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() snap, err := c.SetBackendWeight(ctx, frontend, pool, backend, body.Weight, body.Flush) if err != nil { slog.Warn("admin-set-weight", "maglevd", c.name, "frontend", frontend, "pool", pool, "backend", backend, "weight", body.Weight, "flush", body.Flush, "err", err) http.Error(w, err.Error(), http.StatusBadGateway) return } slog.Info("admin-set-weight", "maglevd", c.name, "frontend", frontend, "pool", pool, "backend", backend, "weight", body.Weight, "flush", body.Flush) writeJSON(w, snap) } // basicAuth wraps a handler in an HTTP basic-auth check. Uses // subtle.ConstantTimeCompare to avoid leaking credential length or // content via response-timing side channels. func basicAuth(realm, user, password string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() // Compare fixed-length byte slices so a wrong username takes // the same time as a wrong password; only the boolean result // matters. uOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 pOK := subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 if !ok || !uOK || !pOK { w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, realm)) http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { slog.Error("json-encode", "err", err) } } // serveSSE handles the long-lived /view/api/events stream. The operational // requirements (retry hint, heartbeat, flush-after-write, X-Accel-Buffering, // context-done teardown) are documented in PLAN_FRONTEND.md §SSE operational // requirements. func serveSSE(w http.ResponseWriter, r *http.Request, broker *Broker) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } h := w.Header() h.Set("Content-Type", "text/event-stream") h.Set("Cache-Control", "no-cache") h.Set("Connection", "keep-alive") h.Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) // Reconnect hint: EventSource default is 3–5s; 2s feels livelier. fmt.Fprintf(w, "retry: 2000\n\n") flusher.Flush() result := broker.Subscribe(r.Header.Get("Last-Event-ID")) defer broker.Unsubscribe(result.Channel) if result.NeedResync { // No id: line — the browser keeps whatever Last-Event-ID it had, // so subsequent reconnects compare against a real event ID. fmt.Fprintf(w, "event: resync\ndata: {}\n\n") flusher.Flush() } for _, ev := range result.ReplayEvents { if err := writeEvent(w, ev); err != nil { return } flusher.Flush() } heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() for { select { case <-r.Context().Done(): return case ev, ok := <-result.Channel: if !ok { return } if err := writeEvent(w, ev); err != nil { return } flusher.Flush() case <-heartbeat.C: if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil { return } flusher.Flush() } } } func writeEvent(w http.ResponseWriter, ev deliveredEvent) error { // "resync" goes out as a named SSE event so the SPA's existing // addEventListener("resync", ...) handler fires (and not the // default onmessage path). Every other event type keeps the // default onmessage path with a JSON body. We still emit an id // so a reconnecting browser can replay from the right point in // the ring; the resync handler is idempotent (a duplicate // replay just triggers a redundant fetchState). if ev.Event.Type == "resync" { _, err := fmt.Fprintf(w, "id: %s\nevent: resync\ndata: {}\n\n", ev.ID) return err } body, err := json.Marshal(ev.Event) if err != nil { return err } _, err = fmt.Fprintf(w, "id: %s\ndata: %s\n\n", ev.ID, body) return err }