Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions admin/actions.go
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)

Comment on lines +14 to +16
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sandboxName comes from the URL and is directly joined into a filesystem path. As-is, values like ../... can escape SandboxHome and 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 ...

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sandbox directory resolution doesn’t match how the catalog is keyed. The catalog key is the full destination path (defaults.UpdateCatalog(sandboxDef.SandboxDir, ...)), but here actions always assume SandboxHome/<name>, and DeleteFromCatalog is called with that derived path. This will break for sandboxes deployed outside the default home and may fail to delete the correct catalog entry; use the catalog destination path consistently for actions and catalog deletes.

Copilot uses AI. Check for mistakes.
return nil
}

// sandboxDirFor resolves the full path for a sandbox name.
func sandboxDirFor(sandboxName string) string {
return filepath.Join(defaults.Defaults().SandboxHome, sandboxName)
}
75 changes: 75 additions & 0 deletions admin/auth.go
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle rand.Read error to avoid weak tokens on failure.

If rand.Read fails (rare but possible on systems with exhausted entropy), the buffer may contain zeros, producing a predictable token. For a security-sensitive value like an OTP or session ID, this error should be handled.

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 NewAuthManager and CreateSession to propagate or handle the error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@admin/auth.go` around lines 69 - 73, generateOTP currently ignores errors
from rand.Read, risking predictable tokens on failure; change generateOTP to
return (string, error) and return an error if rand.Read fails, then update
callers (NewAuthManager and CreateSession) to handle the returned error by
propagating it (or failing session creation) rather than using a zeroed
token—ensure function signatures and all call sites are adjusted to handle the
error path and log/return a clear error when OTP generation fails.

144 changes: 144 additions & 0 deletions admin/handlers.go
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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
if len(parts) != 2 {
if len(parts) != 2 {
http.Error(w, "Invalid sandbox action path format. Expected /api/sandboxes/<name>/<action>", http.StatusBadRequest)
return
}

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
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error from GetAllSandboxes() is ignored when returning the updated HTMX fragment. If catalog reading fails here, the handler will still try to render with a nil/partial list and hide the underlying failure. Handle the error and return an appropriate HTTP error instead of discarding it.

Copilot uses AI. Check for mistakes.
if err := s.templates.ExecuteTemplate(w, "sandbox-list.html", data); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading