-
Notifications
You must be signed in to change notification settings - Fork 2
feat: admin web UI POC (dbdeployer admin ui) #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9e98757
87f1f59
57d81b3
9bb4cd6
4ac543f
0d2876d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| 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 <script> first, then <script>_all for multi-node sandboxes. | ||
| func ExecuteSandboxScript(sandboxName string, script string) error { | ||
| sbDir := sandboxDirFor(sandboxName) | ||
|
|
||
| scriptPath := filepath.Join(sbDir, script) | ||
| if _, err := os.Stat(scriptPath); err != nil { | ||
| // Try the _all variant for multi-node sandboxes. | ||
| scriptPath = filepath.Join(sbDir, script+"_all") | ||
| if _, err2 := os.Stat(scriptPath); err2 != nil { | ||
| return fmt.Errorf("script %q not found in %s", script, sbDir) | ||
| } | ||
| } | ||
|
|
||
| cmd := exec.Command("bash", scriptPath) | ||
| output, err := cmd.CombinedOutput() | ||
| if err != nil { | ||
| return fmt.Errorf("%s failed: %s: %w", script, string(output), err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // DestroySandbox stops a sandbox and removes its directory and catalog entry. | ||
| func DestroySandbox(sandboxName string) error { | ||
| sbDir := sandboxDirFor(sandboxName) | ||
|
|
||
| // 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 { | ||
| return fmt.Errorf("removing %s: %w", sbDir, err) | ||
| } | ||
|
|
||
| // Remove from catalog. The catalog key is the full destination path. | ||
| if err := defaults.DeleteFromCatalog(sbDir); err != nil { | ||
| return fmt.Errorf("removing %s from catalog: %w", sandboxName, err) | ||
| } | ||
|
Comment on lines
+34
to
+51
|
||
| return nil | ||
| } | ||
|
|
||
| // sandboxDirFor resolves the full path for a sandbox name. | ||
| func sandboxDirFor(sandboxName string) string { | ||
| return filepath.Join(defaults.Defaults().SandboxHome, sandboxName) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package admin | ||
|
|
||
| import ( | ||
| "crypto/rand" | ||
| "encoding/hex" | ||
| "net/http" | ||
| "sync" | ||
| "time" | ||
| ) | ||
|
|
||
| type AuthManager struct { | ||
| otp string | ||
| otpUsed bool | ||
| sessions map[string]time.Time | ||
| mu sync.RWMutex | ||
| } | ||
|
|
||
| func NewAuthManager() *AuthManager { | ||
| otp := generateOTP() | ||
| return &AuthManager{ | ||
| otp: otp, | ||
| sessions: make(map[string]time.Time), | ||
| } | ||
| } | ||
|
|
||
| func (a *AuthManager) OTP() string { | ||
| return a.otp | ||
| } | ||
|
|
||
| func (a *AuthManager) ValidateOTP(token string) bool { | ||
| a.mu.Lock() | ||
| defer a.mu.Unlock() | ||
| if a.otpUsed || token != a.otp { | ||
| return false | ||
| } | ||
| a.otpUsed = true | ||
| return true | ||
| } | ||
|
|
||
| func (a *AuthManager) CreateSession() string { | ||
| a.mu.Lock() | ||
| defer a.mu.Unlock() | ||
| sessionID := generateOTP() | ||
| a.sessions[sessionID] = time.Now().Add(1 * time.Hour) | ||
| return sessionID | ||
| } | ||
|
|
||
| func (a *AuthManager) ValidateSession(r *http.Request) bool { | ||
| cookie, err := r.Cookie("dbdeployer_session") | ||
| if err != nil { | ||
| return false | ||
| } | ||
| a.mu.RLock() | ||
| defer a.mu.RUnlock() | ||
| expiry, ok := a.sessions[cookie.Value] | ||
| return ok && time.Now().Before(expiry) | ||
| } | ||
|
|
||
| func (a *AuthManager) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, r *http.Request) { | ||
| if !a.ValidateSession(r) { | ||
| http.Redirect(w, r, "/login", http.StatusSeeOther) | ||
| return | ||
| } | ||
| next(w, r) | ||
| } | ||
| } | ||
|
|
||
| func generateOTP() string { | ||
| b := make([]byte, 16) | ||
| if _, err := rand.Read(b); err != nil { | ||
| panic("crypto/rand.Read failed: " + err.Error()) | ||
| } | ||
| return hex.EncodeToString(b) | ||
| } | ||
|
Comment on lines
+69
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle If Proposed fix-func generateOTP() string {
+func generateOTP() (string, error) {
b := make([]byte, 16)
- _, _ = rand.Read(b)
- return hex.EncodeToString(b)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("generating OTP: %w", err)
+ }
+ return hex.EncodeToString(b), nil
}This would require updating 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,144 @@ | ||||||||||||
| package admin | ||||||||||||
|
|
||||||||||||
| import ( | ||||||||||||
| "encoding/json" | ||||||||||||
| "net/http" | ||||||||||||
| "net/url" | ||||||||||||
| "strings" | ||||||||||||
| "time" | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { | ||||||||||||
| token := r.URL.Query().Get("token") | ||||||||||||
| if token == "" { | ||||||||||||
| if err := s.templates.ExecuteTemplate(w, "login.html", nil); err != nil { | ||||||||||||
| http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) | ||||||||||||
| } | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| if !s.auth.ValidateOTP(token) { | ||||||||||||
| http.Error(w, "Invalid or expired token", http.StatusUnauthorized) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| sessionID := s.auth.CreateSession() | ||||||||||||
| http.SetCookie(w, &http.Cookie{ | ||||||||||||
| Name: "dbdeployer_session", | ||||||||||||
| Value: sessionID, | ||||||||||||
| Path: "/", | ||||||||||||
| HttpOnly: true, | ||||||||||||
| SameSite: http.SameSiteStrictMode, | ||||||||||||
| }) | ||||||||||||
| http.Redirect(w, r, "/", http.StatusSeeOther) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||||||||||||
| if r.URL.Path != "/" { | ||||||||||||
| http.NotFound(w, r) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| sandboxes, err := GetAllSandboxes() | ||||||||||||
| if err != nil { | ||||||||||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| data := map[string]interface{}{ | ||||||||||||
| "Sandboxes": sandboxes, | ||||||||||||
| "Count": len(sandboxes), | ||||||||||||
| } | ||||||||||||
| if err := s.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil { | ||||||||||||
| http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| func (s *Server) handleListSandboxes(w http.ResponseWriter, r *http.Request) { | ||||||||||||
| sandboxes, err := GetAllSandboxes() | ||||||||||||
| if err != nil { | ||||||||||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| w.Header().Set("Content-Type", "application/json") | ||||||||||||
| _ = json.NewEncoder(w).Encode(sandboxes) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| func (s *Server) handleRefreshSandboxList(w http.ResponseWriter, r *http.Request) { | ||||||||||||
| sandboxes, err := GetAllSandboxes() | ||||||||||||
| if err != nil { | ||||||||||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| data := map[string]interface{}{ | ||||||||||||
| "Sandboxes": sandboxes, | ||||||||||||
| "Count": len(sandboxes), | ||||||||||||
| } | ||||||||||||
| if err := s.templates.ExecuteTemplate(w, "sandbox-list.html", data); err != nil { | ||||||||||||
| http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| func (s *Server) handleSandboxAction(w http.ResponseWriter, r *http.Request) { | ||||||||||||
| if r.Method != http.MethodPost { | ||||||||||||
| http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Parse: /api/sandboxes/<name>/<action> | ||||||||||||
| parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/sandboxes/"), "/") | ||||||||||||
| if len(parts) != 2 { | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error message "Bad request" is too generic. Providing a more specific message, such as "Invalid sandbox action path format. Expected /api/sandboxes//", would greatly improve API usability and debugging for clients.
Suggested change
|
||||||||||||
| http.Error(w, "Invalid path format. Expected /api/sandboxes/<name>/<action>", http.StatusBadRequest) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| rawName, action := parts[0], parts[1] | ||||||||||||
|
|
||||||||||||
| // URL-decode the sandbox name (it was urlquery-encoded in the template). | ||||||||||||
| name, err := url.PathUnescape(rawName) | ||||||||||||
| if err != nil { | ||||||||||||
| http.Error(w, "Invalid sandbox name encoding", http.StatusBadRequest) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // 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": | ||||||||||||
| actionErr = ExecuteSandboxScript(name, "start") | ||||||||||||
| case "stop": | ||||||||||||
| actionErr = ExecuteSandboxScript(name, "stop") | ||||||||||||
| case "destroy": | ||||||||||||
| actionErr = DestroySandbox(name) | ||||||||||||
| default: | ||||||||||||
| http.Error(w, "Unknown action", http.StatusBadRequest) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if actionErr != nil { | ||||||||||||
| http.Error(w, actionErr.Error(), http.StatusInternalServerError) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Wait briefly for start/stop to take effect before reading status. | ||||||||||||
| // Start scripts launch mysqld in the background; the PID file | ||||||||||||
| // and status script need a moment to reflect the new state. | ||||||||||||
| if action == "start" || action == "stop" { | ||||||||||||
| time.Sleep(2 * time.Second) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Return updated sandbox list fragment for HTMX. | ||||||||||||
| 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), | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+131
to
+140
|
||||||||||||
| if err := s.templates.ExecuteTemplate(w, "sandbox-list.html", data); err != nil { | ||||||||||||
| http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) | ||||||||||||
| } | ||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||
| } | ||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sandboxNamecomes from the URL and is directly joined into a filesystem path. As-is, values like../...can escapeSandboxHomeand make this endpoint execute/remove arbitrary paths after auth (and auth here is only a session cookie). Resolve the target sandbox via the catalog and validate the identifier strictly (e.g., only allow exact catalog keys or a validated name set), and reject names containing path separators or...