package web import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os/exec" "runtime" "time" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/keyring" "gitgud.io/mike/mpv-manager/pkg/log" ) const sudoValidationTimeout = 15 * time.Second // ============================================================================ // Keyring API Endpoints (for sudo password storage) // ============================================================================ // handleKeyringAuthRoutes routes /api/keyring/auth based on HTTP method // POST -> store password, DELETE -> clear password func (s *Server) handleKeyringAuthRoutes(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: s.handleKeyringAuthStore(w, r) case http.MethodDelete: s.handleKeyringAuthClear(w, r) default: sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") } } // handleKeyringStatusAPI returns keyring status information // GET /api/keyring/status func (s *Server) handleKeyringStatusAPI(w http.ResponseWriter, r *http.Request) { status := keyring.DetectStatusContext(r.Context()) statusError := "" if status.Error != "" { log.Warn(constants.LogPrefixAPI + "Keyring status detection failed: " + status.Error) statusError = "Keyring status unavailable" } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "canElevateWithoutPassword": installer.CanElevateWithoutPassword(r.Context()), "availableBackends": status.AvailableBackends, "activeBackend": status.ActiveBackend, "daemonRunning": status.DaemonRunning, "hasPassword": status.HasPassword, "installHint": status.InstallHint, "desktopEnv": status.DesktopEnv, "distro": status.Distro, "needsSetup": status.ActiveBackend == keyring.BackendNone, "platform": status.Platform, "error": statusError, }) } // handleKeyringAuthStore stores sudo password after validating it // POST /api/keyring/auth func (s *Server) handleKeyringAuthStore(w http.ResponseWriter, r *http.Request) { // Rate-limit auth attempts (brute-force protection, all platforms) if !s.keyringAuthLimiter.allow(rateLimitMaxAttempts, rateLimitWindow) { sendJSONError(w, http.StatusTooManyRequests, "Too many attempts, please try again later") return } var req struct { Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request") return } if req.Password == "" { sendJSONError(w, http.StatusBadRequest, "Password is required") return } // On Linux, validate password with sudo before storing if runtime.GOOS == "linux" { ctx, cancel := context.WithTimeout(r.Context(), sudoValidationTimeout) defer cancel() // Invalidate any cached sudo timestamp before checking the supplied // password. Without -k, sudo may accept an incorrect password merely // because this user already has a valid timestamp. cmd := exec.CommandContext(ctx, "sudo", "-k", "-S", "-v") cmd.Stdin = bytes.NewBufferString(req.Password + "\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() stdoutStr := stdout.String() stderrStr := stderr.String() if ctx.Err() != nil { log.Warn(constants.LogPrefixAPI + "Keyring auth: sudo validation timed out or was cancelled") sendJSONError(w, http.StatusGatewayTimeout, "Sudo authentication timed out") return } if err != nil { log.Debug(fmt.Sprintf("Keyring auth validation failed: err=%v, stdout=%q, stderr=%q", err, stdoutStr, stderrStr)) log.Warn(constants.LogPrefixAPI + "Keyring auth: invalid password provided") sendJSONError(w, http.StatusUnauthorized, "Invalid password") return } log.Debug(fmt.Sprintf("Keyring auth validation succeeded: stdout=%q, stderr=%q", stdoutStr, stderrStr)) } // Open keyring kr, err := keyring.Open() if err != nil { log.Error(constants.LogPrefixAPI + "Failed to open keyring: " + err.Error()) sendJSONError(w, http.StatusServiceUnavailable, "Secure password storage is unavailable") return } // Store in keyring if err := kr.StorePasswordContext(r.Context(), req.Password); err != nil { log.Error(constants.LogPrefixAPI + "Failed to store password: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to store password securely") return } log.Info(constants.LogPrefixAPI + "Password stored in keyring successfully") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, }) } // handleKeyringAuthClear clears stored password // DELETE /api/keyring/auth func (s *Server) handleKeyringAuthClear(w http.ResponseWriter, r *http.Request) { // Open keyring kr, err := keyring.Open() if err != nil { log.Error(constants.LogPrefixAPI + "Failed to open keyring: " + err.Error()) sendJSONError(w, http.StatusServiceUnavailable, "Secure password storage is unavailable") return } if err := kr.DeletePasswordContext(r.Context()); err != nil { log.Error(constants.LogPrefixAPI + "Failed to delete password: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to clear stored password") return } log.Info(constants.LogPrefixAPI + "Password deleted from keyring successfully") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, }) }