From 9e987570eb007d6d633842054189da54630a3e81 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 18:51:02 +0000 Subject: [PATCH 1/6] docs: add admin web UI POC design spec --- .../2026-03-24-admin-webui-poc-design.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-24-admin-webui-poc-design.md diff --git a/docs/superpowers/specs/2026-03-24-admin-webui-poc-design.md b/docs/superpowers/specs/2026-03-24-admin-webui-poc-design.md new file mode 100644 index 0000000..3fa195f --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-admin-webui-poc-design.md @@ -0,0 +1,132 @@ +# Admin Web UI POC Design + +**Date:** 2026-03-24 +**Author:** Rene (ProxySQL) +**Status:** POC + +## Goal + +Prove that dbdeployer can be a platform, not just a CLI. A `dbdeployer admin` command launches a localhost web dashboard showing all deployed sandboxes with start/stop/destroy controls. + +## Scope (POC only) + +- Dashboard showing all sandboxes as cards grouped by topology +- Start/stop/destroy actions via the UI +- OTP authentication (CLI generates token, browser validates) +- Localhost only (127.0.0.1) +- Go templates + HTMX, embedded in binary + +## NOT in scope (future) + +- Deploy new sandboxes via UI +- Real-time log streaming +- Topology graph visualization +- Multi-user / remote access +- Persistent sessions + +## Architecture + +``` +dbdeployer admin + └─ starts HTTP server on 127.0.0.1: + └─ generates OTP, prints to terminal + └─ opens browser to http://127.0.0.1:/login?token= + └─ serves embedded HTML templates via Go's html/template + └─ HTMX handles dynamic actions (no page reload for start/stop/destroy) + └─ API endpoints read sandbox catalog + execute lifecycle commands +``` + +### Authentication Flow + +1. `dbdeployer admin` generates a random OTP (32-char hex) +2. Prints: `Admin UI: http://127.0.0.1:9090/login?token=` +3. Browser hits `/login?token=` → server validates → sets session cookie +4. Session cookie used for all subsequent requests +5. OTP is single-use (invalidated after first login) +6. Session expires when server stops (in-memory) + +### API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/login` | Validate OTP, set session cookie, redirect to dashboard | +| GET | `/` | Dashboard (HTML) | +| GET | `/api/sandboxes` | JSON list of all sandboxes | +| POST | `/api/sandboxes/:name/start` | Start a sandbox | +| POST | `/api/sandboxes/:name/stop` | Stop a sandbox | +| POST | `/api/sandboxes/:name/destroy` | Destroy a sandbox (requires confirmation) | + +### Dashboard Layout + +**Header:** "dbdeployer admin" + sandbox count + server uptime + +**Sandbox cards grouped by topology:** + +``` +┌─ Replication: rsandbox_8_4_4 ────────────────────────┐ +│ │ +│ ┌─ master ─────────┐ ┌─ node1 ──────────┐ │ +│ │ Port: 8404 │ │ Port: 8405 │ │ +│ │ ● Running │ │ ● Running │ │ +│ │ [Stop] │ │ [Stop] │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌─ node2 ──────────┐ ┌─ proxysql ───────┐ │ +│ │ Port: 8406 │ │ Port: 6032/6033 │ │ +│ │ ● Running │ │ ● Running │ │ +│ │ [Stop] │ │ [Stop] │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ [Stop All] [Destroy] ──────────────────────────────│ +└────────────────────────────────────────────────────────┘ + +┌─ Single: msb_8_4_4 ──────────────────────────────────┐ +│ Port: 8404 │ ● Running │ [Stop] [Destroy] │ +└────────────────────────────────────────────────────────┘ +``` + +### Sandbox Data Source + +Read from `~/.dbdeployer/sandboxes.json` (the existing sandbox catalog). Each entry has: +- Sandbox name and directory +- Type (single, multiple, replication, group, etc.) +- Ports +- Nodes (for multi-node topologies) + +Status is determined by checking if the sandbox's PID file exists / process is running. + +### Technology + +- **Server:** Go `net/http` (stdlib, no framework) +- **Templates:** Go `html/template` with `//go:embed` +- **Interactivity:** HTMX (loaded from CDN or embedded) +- **Styling:** Inline CSS in the template (single file, dark theme matching the website) +- **Session:** In-memory map, cookie-based + +## File Structure + +``` +cmd/admin.go # Cobra command: dbdeployer admin +admin/ + server.go # HTTP server, routes, middleware + auth.go # OTP generation, session management + handlers.go # API handlers (list, start, stop, destroy) + sandbox_status.go # Read catalog, check process status + templates/ + layout.html # Base layout (head, nav, footer) + dashboard.html # Dashboard with sandbox cards + login.html # Login page (auto-submits with OTP) + components/ + sandbox-card.html # Single sandbox card partial + topology-group.html # Topology group wrapper partial + static/ + htmx.min.js # HTMX library (embedded) + style.css # Dashboard styles +``` + +All templates and static files embedded via `//go:embed admin/templates/* admin/static/*`. + +## Port Selection + +Default: 9090. If busy, find next free port. Print the URL to terminal. +Flag: `--port` to override. From 87f1f598c54a8af57f563b140a9c69a60da3a1b5 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 18:56:19 +0000 Subject: [PATCH 2/6] feat: add admin web UI POC - `dbdeployer admin ui` starts localhost dashboard on port 9090 - OTP authentication: CLI prints token, browser validates - Dashboard shows sandbox cards grouped by topology - HTMX-powered start/stop/destroy actions (no page reload) - Dark theme, Go templates embedded via //go:embed - Reads existing sandbox catalog, checks PID for status --- admin/actions.go | 56 +++++++ admin/auth.go | 73 +++++++++ admin/handlers.go | 103 +++++++++++++ admin/sandbox_status.go | 151 +++++++++++++++++++ admin/server.go | 84 +++++++++++ admin/static/.gitkeep | 0 admin/static/placeholder.txt | 1 + admin/templates/components/sandbox-list.html | 58 +++++++ admin/templates/dashboard.html | 126 ++++++++++++++++ admin/templates/login.html | 87 +++++++++++ cmd/admin.go | 21 +++ 11 files changed, 760 insertions(+) create mode 100644 admin/actions.go create mode 100644 admin/auth.go create mode 100644 admin/handlers.go create mode 100644 admin/sandbox_status.go create mode 100644 admin/server.go create mode 100644 admin/static/.gitkeep create mode 100644 admin/static/placeholder.txt create mode 100644 admin/templates/components/sandbox-list.html create mode 100644 admin/templates/dashboard.html create mode 100644 admin/templates/login.html diff --git a/admin/actions.go b/admin/actions.go new file mode 100644 index 0000000..f3cad08 --- /dev/null +++ b/admin/actions.go @@ -0,0 +1,56 @@ +package admin + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/ProxySQL/dbdeployer/defaults" +) + +// ExecuteSandboxScript runs a lifecycle script (start, stop, restart) in a sandbox directory. +// It tries + + + +
+

dbdeployer admin

+ {{.Count}} sandbox{{if ne .Count 1}}es{{end}} +
+
+
+ {{template "sandbox-list.html" .}} +
+
+ + diff --git a/admin/templates/login.html b/admin/templates/login.html new file mode 100644 index 0000000..f1898e8 --- /dev/null +++ b/admin/templates/login.html @@ -0,0 +1,87 @@ + + + + + + dbdeployer admin — login + + + +
+

dbdeployer admin

+

Enter your one-time access token

+
+ + + +
+

The token was printed to the terminal when you started dbdeployer admin ui.

+
+ + diff --git a/cmd/admin.go b/cmd/admin.go index 03a2569..131829a 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -17,6 +17,7 @@ package cmd import ( "fmt" + "github.com/ProxySQL/dbdeployer/admin" "github.com/ProxySQL/dbdeployer/defaults" "os" "path" @@ -518,6 +519,23 @@ func SandboxNames(n int) cobra.PositionalArgs { } } +var adminUiCmd = &cobra.Command{ + Use: "ui", + Short: "Start the admin web UI", + Long: `Starts a local web server with a dashboard for managing deployed sandboxes. +Opens a browser with a one-time authentication token.`, + Run: func(cmd *cobra.Command, args []string) { + port, _ := cmd.Flags().GetInt("port") + server, err := admin.NewServer(port) + if err != nil { + common.Exitf(1, "Failed to start admin server: %s", err) + } + if err := server.Start(); err != nil { + common.Exitf(1, "Server error: %s", err) + } + }, +} + func init() { rootCmd.AddCommand(adminCmd) adminCmd.AddCommand(adminLockCmd) @@ -526,6 +544,7 @@ func init() { adminCmd.AddCommand(adminCapabilitiesCmd) adminCmd.AddCommand(adminSetDefaultCmd) adminCmd.AddCommand(adminRemoveDefaultCmd) + adminCmd.AddCommand(adminUiCmd) adminUpgradeCmd.Flags().BoolP(globals.VerboseLabel, "", false, "Shows upgrade operations") adminUpgradeCmd.Flags().BoolP(globals.DryRunLabel, "", false, "Shows upgrade operations, but don't execute them") @@ -533,4 +552,6 @@ func init() { defaults.Defaults().DefaultSandboxExecutable, "Name of the executable to run commands in the default sandbox") adminRemoveDefaultCmd.PersistentFlags().StringP(globals.DefaultSandboxExecutable, "", defaults.Defaults().DefaultSandboxExecutable, "Name of the executable to run commands in the default sandbox") + + adminUiCmd.Flags().Int("port", 9090, "Port for the admin web UI") } From 57d81b3ba2c5b5eec15bede5c105a7b51d680783 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 19:14:46 +0000 Subject: [PATCH 3/6] fix: address code review issues (security, error handling, timeouts) - auth.go: panic on crypto/rand failure; store session expiry and check it in ValidateSession - handlers.go: path traversal guard on sandbox name; improved error message; handle GetAllSandboxes error after action - actions.go: log warning instead of silently ignoring stop failure during destroy - server.go: listen before opening browser to avoid race; add ReadTimeout/WriteTimeout/IdleTimeout; Windows support; openBrowser returns error - sandbox_status.go: replace os.Signal(nil) with syscall.Signal(0) for portable process liveness check - dashboard.html: add SRI integrity hash to HTMX CDN script tag - sandbox-list.html: URL-encode sandbox names in hx-post attributes --- admin/actions.go | 6 ++-- admin/auth.go | 10 ++++--- admin/handlers.go | 26 +++++++++++----- admin/sandbox_status.go | 3 +- admin/server.go | 31 +++++++++++++++----- admin/templates/components/sandbox-list.html | 6 ++-- admin/templates/dashboard.html | 2 +- 7 files changed, 58 insertions(+), 26 deletions(-) diff --git a/admin/actions.go b/admin/actions.go index f3cad08..00408ec 100644 --- a/admin/actions.go +++ b/admin/actions.go @@ -35,8 +35,10 @@ func ExecuteSandboxScript(sandboxName string, script string) error { func DestroySandbox(sandboxName string) error { sbDir := sandboxDirFor(sandboxName) - // Try stop (ignore errors — sandbox may already be stopped). - _ = ExecuteSandboxScript(sandboxName, "stop") + // Try stop (log warning if fails — sandbox may already be stopped). + if err := ExecuteSandboxScript(sandboxName, "stop"); err != nil { + fmt.Printf("Warning: failed to stop sandbox %s during destruction: %v\n", sandboxName, err) + } // Remove directory. if err := os.RemoveAll(sbDir); err != nil { diff --git a/admin/auth.go b/admin/auth.go index 6d33c22..32ec204 100644 --- a/admin/auth.go +++ b/admin/auth.go @@ -41,7 +41,7 @@ func (a *AuthManager) CreateSession() string { a.mu.Lock() defer a.mu.Unlock() sessionID := generateOTP() - a.sessions[sessionID] = time.Now() + a.sessions[sessionID] = time.Now().Add(1 * time.Hour) return sessionID } @@ -52,8 +52,8 @@ func (a *AuthManager) ValidateSession(r *http.Request) bool { } a.mu.RLock() defer a.mu.RUnlock() - _, ok := a.sessions[cookie.Value] - return ok + expiry, ok := a.sessions[cookie.Value] + return ok && time.Now().Before(expiry) } func (a *AuthManager) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { @@ -68,6 +68,8 @@ func (a *AuthManager) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { func generateOTP() string { b := make([]byte, 16) - _, _ = rand.Read(b) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand.Read failed: " + err.Error()) + } return hex.EncodeToString(b) } diff --git a/admin/handlers.go b/admin/handlers.go index f7850d5..ad475e4 100644 --- a/admin/handlers.go +++ b/admin/handlers.go @@ -68,31 +68,41 @@ func (s *Server) handleSandboxAction(w http.ResponseWriter, r *http.Request) { // Parse: /api/sandboxes// parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/sandboxes/"), "/") if len(parts) != 2 { - http.Error(w, "Bad request", http.StatusBadRequest) + http.Error(w, "Invalid path format. Expected /api/sandboxes//", http.StatusBadRequest) return } name, action := parts[0], parts[1] - var err error + // Reject names that could escape the sandbox directory. + if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { + http.Error(w, "Invalid sandbox name", http.StatusBadRequest) + return + } + + var actionErr error switch action { case "start": - err = ExecuteSandboxScript(name, "start") + actionErr = ExecuteSandboxScript(name, "start") case "stop": - err = ExecuteSandboxScript(name, "stop") + actionErr = ExecuteSandboxScript(name, "stop") case "destroy": - err = DestroySandbox(name) + actionErr = DestroySandbox(name) default: http.Error(w, "Unknown action", http.StatusBadRequest) return } - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if actionErr != nil { + http.Error(w, actionErr.Error(), http.StatusInternalServerError) return } // Return updated sandbox list fragment for HTMX. - sandboxes, _ := GetAllSandboxes() + sandboxes, err := GetAllSandboxes() + if err != nil { + http.Error(w, "Action succeeded but failed to refresh: "+err.Error(), http.StatusInternalServerError) + return + } data := map[string]interface{}{ "Sandboxes": sandboxes, "Count": len(sandboxes), diff --git a/admin/sandbox_status.go b/admin/sandbox_status.go index 5fe5590..cba5d76 100644 --- a/admin/sandbox_status.go +++ b/admin/sandbox_status.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "github.com/ProxySQL/dbdeployer/common" "github.com/ProxySQL/dbdeployer/defaults" @@ -146,6 +147,6 @@ func isRunning(sandboxDir string) bool { if err != nil { return false } - err = proc.Signal(os.Signal(nil)) + err = proc.Signal(syscall.Signal(0)) return err == nil } diff --git a/admin/server.go b/admin/server.go index c10df1d..d837f1d 100644 --- a/admin/server.go +++ b/admin/server.go @@ -8,6 +8,7 @@ import ( "net/http" "os/exec" "runtime" + "time" ) //go:embed templates static @@ -59,26 +60,42 @@ func (s *Server) Start() error { fmt.Printf("\n dbdeployer admin\n") fmt.Printf(" ────────────────────────────────\n") fmt.Printf(" URL: %s\n", url) - fmt.Printf(" (opening browser...)\n\n") + fmt.Printf(" Press Ctrl+C to stop\n\n") - openBrowser(url) + // Open browser AFTER listener is ready + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } listener, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("listen on %s: %w", addr, err) } - return http.Serve(listener, mux) + + // Now that listener is ready, open browser + if err := openBrowser(url); err != nil { + fmt.Printf(" Warning: could not open browser: %v\n", err) + fmt.Printf(" Open this URL manually: %s\n\n", url) + } + + return server.Serve(listener) } -func openBrowser(url string) { +func openBrowser(url string) error { var cmd *exec.Cmd switch runtime.GOOS { case "linux": cmd = exec.Command("xdg-open", url) case "darwin": cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return fmt.Errorf("unsupported OS: %s", runtime.GOOS) } - if cmd != nil { - _ = cmd.Start() - } + return cmd.Start() } diff --git a/admin/templates/components/sandbox-list.html b/admin/templates/components/sandbox-list.html index 5f98b7e..fd39c91 100644 --- a/admin/templates/components/sandbox-list.html +++ b/admin/templates/components/sandbox-list.html @@ -32,19 +32,19 @@ {{end}}