Files
felhom-controller/controller/internal/web/poweroff_handlers.go
T
admin 201f936e9a
gates / gates (push) Successful in 48s
v0.310.0: „A gép kikapcsolása" — the household turns the whole machine off (R-998); honest restart text (R-999)
POST /api/server/poweroff (dashboard login, POST only, the typed word KIKAPCSOLÁS / TURN OFF)
→ agent POST /host/poweroff (MinAgent 0.155.0; hidden for an older agent). The controller refuses
during its own backup/restore/app update; the agent's reasons are shown as household sentences.
„Kiszolgáló újraindítása" becomes „A Felhom újraindítása" and says it restarts the Felhom part;
/api/server/reboot is POST only (R-983). Tests red-proofed; parity fixtures regenerated on purpose.
2026-10-13 20:17:30 +02:00

143 lines
5.4 KiB
Go

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": "<the word>"}.
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})
}