package handlers import ( "encoding/json" "fmt" "net/http" "strings" "forge-mesh/internal/alerts" "forge-mesh/internal/auth" "forge-mesh/internal/fleet" ) // CrucibleHandler serves batch terminal endpoints. type CrucibleHandler struct { Store *fleet.Store Hub *fleet.Hub Crucible *fleet.CrucibleStore Alerts *alerts.Notifier OperatorClearance int } type batchRequest struct { Command string `json:"command"` HostIDs []string `json:"host_ids"` All bool `json:"all"` } func (h *CrucibleHandler) Dispatch(w http.ResponseWriter, r *http.Request) { if err := fleet.CheckAction(h.OperatorClearance, fleet.ActionCrucible); err != nil { auth.JSON(w, http.StatusForbidden, map[string]any{"error": err.Error()}) return } var req batchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } req.Command = strings.TrimSpace(req.Command) if req.Command == "" { http.Error(w, "command required", http.StatusBadRequest) return } hostIDs := req.HostIDs if req.All || len(hostIDs) == 0 { hosts, err := h.Store.ListHosts() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } for _, host := range hosts { if host.Status == "online" || host.Status == "mining" { hostIDs = append(hostIDs, host.ID) } } } job := h.Crucible.Create(req.Command, hostIDs) action := mapCommandToAction(req.Command) for _, hostID := range hostIDs { host, _ := h.Store.GetHost(hostID) hostname := hostID if host != nil { hostname = host.Hostname } if err := fleet.CheckAction(h.OperatorClearance, action); err != nil { h.Crucible.AddResult(job.ID, fleet.BatchResult{ HostID: hostID, Hostname: hostname, Status: "denied", Message: err.Error(), }) continue } cmd, err := h.Hub.DispatchCommand(hostID, action, map[string]any{"raw": req.Command}) if err != nil { h.Crucible.AddResult(job.ID, fleet.BatchResult{ HostID: hostID, Hostname: hostname, Status: "error", Message: err.Error(), }) continue } h.Crucible.AddResult(job.ID, fleet.BatchResult{ HostID: hostID, Hostname: hostname, Status: "dispatched", CommandID: cmd.ID, }) } h.Crucible.Complete(job.ID, "completed") if h.Alerts != nil && h.Alerts.Enabled() { h.Alerts.Send(alerts.CrucibleEvent(job.ID, len(hostIDs), req.Command)) } _ = h.Store.InsertSeerEvent("", "crucible", fmt.Sprintf(`{"job_id":"%s","command":%q}`, job.ID, req.Command)) auth.JSON(w, http.StatusOK, job) } func (h *CrucibleHandler) GetJob(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") job, ok := h.Crucible.Get(id) if !ok { http.Error(w, "not found", http.StatusNotFound) return } auth.JSON(w, http.StatusOK, job) } func (h *CrucibleHandler) History(w http.ResponseWriter, r *http.Request) { auth.JSON(w, http.StatusOK, map[string]any{ "jobs": h.Crucible.History(20), }) } func mapCommandToAction(cmd string) string { lower := strings.ToLower(strings.TrimSpace(cmd)) switch { case strings.HasPrefix(lower, "pause"): return fleet.ActionPause case strings.HasPrefix(lower, "resume"): return fleet.ActionResume case strings.HasPrefix(lower, "reboot"): return fleet.ActionReboot case strings.HasPrefix(lower, "screenshot"): return fleet.ActionScreenshot case strings.HasPrefix(lower, "shell"): return fleet.ActionShell default: return fleet.ActionStatus } }