201f936e9a
gates / gates (push) Successful in 48s
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.
143 lines
5.4 KiB
Go
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})
|
|
}
|