package web import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" ) // ── „A gép kikapcsolása" — the household turns the whole machine off (R-998) ───────────────────────────── // // Design: felhom.eu documentation/architecture/14-host-power-off.md. The controller is the front door only; the // agent's POST /host/poweroff holds the real guards (10 min after boot, one per boot, no other guest, no job). // Here: dashboard login (RequireAuth + CSRF, wired in main.go), POST only, the typed confirmation word, and the // controller's OWN busy work (a backup/restore/app update the agent cannot see). Pinned by poweroff_handlers_test.go. // powerOffAgent is the agent seam: the call, and the version gate. type powerOffAgent interface { HostPowerOff(ctx context.Context) error Supports(ctx context.Context, f agentapi.Feature) agentapi.SupportState } // powerOffConfirmWords are what the household may type. The unaccented spelling is accepted on purpose: a phone or // a foreign keyboard may not have „Á", and the word is a deliberate act, not a password. var powerOffConfirmWords = map[string]bool{"KIKAPCSOLÁS": true, "KIKAPCSOLAS": true, "TURN OFF": true} func powerOffConfirmed(typed string) bool { return powerOffConfirmWords[strings.ToUpper(strings.Join(strings.Fields(typed), " "))] } func (s *Server) powerOffAgentFor() (powerOffAgent, error) { if s.powerOffAgentFn != nil { return s.powerOffAgentFn() } c, err := s.agentClient() if err != nil { return nil, err } return c, nil } // powerOffLocalBusy is the controller-side work the agent cannot see. func (s *Server) powerOffLocalBusy() (bool, string) { if s.powerOffBusyFn != nil { return s.powerOffBusyFn() } if s.backupMgr != nil && (s.backupMgr.IsRunning() || s.backupMgr.RestoreStatus().Running) { return true, "a backup or restore is running" } if s.stackMgr != nil && s.stackMgr.AnyUpdating() { return true, "an app update is running" } return false, "" } // powerOffCardData fills the settings card: hidden (with a note) only on a DEFINITE "agent too old". func (s *Server) powerOffCardData(data map[string]interface{}) { data["PowerOffSupported"] = true agent, err := s.powerOffAgentFor() if err != nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if agent.Supports(ctx, agentapi.FeatureHostPowerOff) == agentapi.SupportNo { data["PowerOffSupported"] = false } } // powerOffRefusalKey maps the agent's reason to the household sentence. func powerOffRefusalKey(reason string) string { switch reason { case agentapi.PowerOffTooSoon: return "poweroff.refused.too_soon" case agentapi.PowerOffAlreadyAsked: return "poweroff.refused.already" case agentapi.PowerOffBackup, agentapi.PowerOffRestoreTest: return "poweroff.refused.backup" case agentapi.PowerOffOSUpdate, agentapi.PowerOffCtrlUpdate: return "poweroff.refused.update" case agentapi.PowerOffDrive: return "poweroff.refused.drive" case agentapi.PowerOffOtherGuests: return "poweroff.refused.other_guests" default: return "poweroff.refused.generic" } } // HandleServerPowerOff is POST /api/server/poweroff {"confirm": ""}. func (s *Server) HandleServerPowerOff(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) writeDiskJSON(w, http.StatusMethodNotAllowed, false, "POST only", nil) return } r.Body = http.MaxBytesReader(w, r.Body, 4<<10) var req struct { Confirm string `json:"confirm"` } _ = json.NewDecoder(r.Body).Decode(&req) if !powerOffConfirmed(req.Confirm) { writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "poweroff.confirm_required"), map[string]any{"code": "confirm_required"}) return } if busy, why := s.powerOffLocalBusy(); busy { s.logger.Printf("[INFO] [web] power-off refused: %s", why) writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "poweroff.refused.backup"), map[string]any{"code": "controller_busy"}) return } agent, err := s.powerOffAgentFor() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.msg(r, "poweroff.agent_down"), nil) return } if agent.Supports(r.Context(), agentapi.FeatureHostPowerOff) == agentapi.SupportNo { writeDiskJSON(w, http.StatusPreconditionFailed, false, s.msg(r, "poweroff.agent_outdated"), map[string]any{"code": "agent_outdated"}) return } err = agent.HostPowerOff(r.Context()) if ref, ok := agentapi.IsPowerOffRefused(err); ok { s.logger.Printf("[INFO] [web] power-off refused by the agent: %s (%s)", ref.Reason, ref.Detail) writeDiskJSON(w, http.StatusConflict, false, s.msg(r, powerOffRefusalKey(ref.Reason)), map[string]any{"code": ref.Reason}) return } var se *agentapi.StatusError if errors.As(err, &se) && se.Code == http.StatusNotFound { writeDiskJSON(w, http.StatusPreconditionFailed, false, s.msg(r, "poweroff.agent_outdated"), map[string]any{"code": "agent_outdated"}) return } if err != nil { s.logger.Printf("[ERROR] [web] power-off request failed: %v", err) writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "poweroff.agent_down"), nil) return } s.logger.Printf("[WARN] [web] MACHINE POWER-OFF accepted (household request from the dashboard)") writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"powering_off": true}) }