package api import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "bat-api/internal/backendrpc" ) const adminControlMaxBodyBytes = 1024 const adminScheduleMaxBodyBytes = 64 * 1024 type adminControlRequest struct { Force bool `json:"force"` } func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } body := AdminIndexResponse{ Service: "bat-api", Panel: "admin", Status: "available", Links: []string{ "/healthz", "/readyz", "/v1/bootstrap", "/v1/release", "/v1/resources", "/openapi.yaml", "/admin/schedules", }, Controls: []string{ "/admin/control/reload", "/admin/control/refresh", "/admin/control/restart", "/admin/control/sync", "/admin/control/verify", "/admin/control/repair", "/admin/control/catalog-refresh", "/admin/control/schedule-add", "/admin/control/schedule-update", "/admin/control/schedule-remove", "/admin/control/schedule-run", "/admin/control/translation-task-update", }, } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) return } writeNoStoreJSON(w, http.StatusOK, body) } func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } if !s.requireAdminToken(w, r) { return } action := strings.TrimPrefix(r.URL.Path, "/admin/control/") if action == "" || strings.Contains(action, "/") { writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") return } if strings.HasPrefix(action, "schedule-") { s.handleAdminScheduleControl(w, r, action) return } if action == "translation-task-update" { s.handleAdminTranslationTaskUpdate(w, r) return } request, ok := decodeAdminControlRequest(w, r) if !ok { return } backend, ok := s.backend.(ControlBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "control_backend_unavailable", "Rust bat control backend is unavailable") return } var ( method string result any err error ) switch action { case "reload": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "reload does not accept force") return } method = "daemon.reload" result, err = backend.DaemonReload(r.Context()) case "refresh": method = "daemon.refresh" result, err = backend.DaemonRefresh(r.Context(), request.Force) case "restart": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "restart does not accept force") return } method = "daemon.restart" result, err = backend.DaemonRestart(r.Context()) case "sync": method = "resource.sync" result, err = backend.ResourceSync(r.Context(), request.Force) case "verify": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "verify does not accept force") return } method = "resource.verify" result, err = backend.ResourceVerify(r.Context()) case "repair": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "repair does not accept force") return } method = "resource.repair" result, err = backend.ResourceRepair(r.Context()) case "catalog-refresh": method = "catalog.refresh" result, err = backend.CatalogRefresh(r.Context(), request.Force) case "stop", "clean-stable": writeErrorJSON(w, http.StatusForbidden, "control_not_allowed", "control action is not exposed by bat-api") return default: writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") return } if err != nil { s.writeControlBackendError(w, action, err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: action, RPCMethod: method, Status: "accepted", Result: result, }) } func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(TranslationBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable") return } var params backendrpc.TranslationTaskUpdateParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" { writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", "translation task update requires task_id and status") return } result, err := backend.TranslationTaskUpdate(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-task-update", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "translation-task-update", RPCMethod: "translation.task.update", Status: "accepted", Result: result, }) } func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } if !s.requireAdminToken(w, r) { return } backend, ok := s.backend.(ScheduleBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") return } params, err := scheduleListParams(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_query", err.Error()) return } result, err := backend.ScheduleList(r.Context(), params) if err != nil { s.writeControlBackendError(w, "schedule-list", err) return } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) return } writeNoStoreJSON(w, http.StatusOK, result) } func scheduleListParams(r *http.Request) (backendrpc.ScheduleListParams, error) { query := r.URL.Query() params := backendrpc.ScheduleListParams{ ID: query.Get("id"), Group: query.Get("group"), } if raw := query.Get("enabled"); raw != "" { enabled, err := strconv.ParseBool(raw) if err != nil { return backendrpc.ScheduleListParams{}, errors.New("enabled must be a boolean") } params.Enabled = &enabled } return params, nil } func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) { backend, ok := s.backend.(ScheduleBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") return } var ( method string result json.RawMessage err error ) switch action { case "schedule-add", "schedule-update", "schedule-remove": var params backendrpc.ScheduleMutationParams if !decodeAdminScheduleJSON(w, r, ¶ms) { return } switch action { case "schedule-add": method = "schedule.add" result, err = backend.ScheduleAdd(r.Context(), params) case "schedule-update": method = "schedule.update" result, err = backend.ScheduleUpdate(r.Context(), params) default: method = "schedule.remove" result, err = backend.ScheduleRemove(r.Context(), params) } case "schedule-run": var params backendrpc.ScheduleRunParams if !decodeAdminScheduleJSON(w, r, ¶ms) { return } method = "schedule.run" result, err = backend.ScheduleRun(r.Context(), params) default: writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") return } if err != nil { s.writeControlBackendError(w, action, err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: action, RPCMethod: method, Status: "accepted", Result: result, }) } func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool { if s.cfg.AuthToken == "" { writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN") return false } if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) { w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api-admin"`) writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token") return false } return true } func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any) bool { return decodeAdminJSON(w, r, target, "invalid_schedule_params", "schedule request") } func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target any) bool { return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation task request") } func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool { if r.Body == nil { return true } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminScheduleMaxBodyBytes)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { if errors.Is(err, io.EOF) { return true } writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" must be a JSON object") return false } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" must contain exactly one JSON object") return false } return true } func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) { var request adminControlRequest if r.Body == nil { return request, true } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminControlMaxBodyBytes)) decoder.DisallowUnknownFields() if err := decoder.Decode(&request); err != nil { if errors.Is(err, io.EOF) { return request, true } writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must be a JSON object with an optional force boolean") return adminControlRequest{}, false } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must contain exactly one JSON object") return adminControlRequest{}, false } return request, true } func (s *Server) writeControlBackendError(w http.ResponseWriter, action string, err error) { status := http.StatusBadGateway code := "control_backend_failed" message := "Rust bat rejected the control request" switch { case errors.Is(err, context.DeadlineExceeded): status = http.StatusGatewayTimeout code = "control_backend_timeout" message = "Rust bat control request timed out" case errors.Is(err, context.Canceled): status = http.StatusRequestTimeout code = "control_request_canceled" message = "control request was canceled" default: var apiErr *backendrpc.APIError if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" { status = http.StatusNotImplemented code = "control_not_implemented" message = "Rust bat does not implement this control action" } } s.logger.Printf("bat-api control action=%s error=%v", action, err) writeErrorJSON(w, status, code, message) }