Files
felhom.eu/documentation/architecture/14-host-power-off.md
T

91 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 14 — Turning the machine off: „A gép kikapcsolása" (R-998)
> **What this is:** the household's way to switch the whole machine off — the path, the guards, what the hub does, and
> the threat it adds. Written 2026-10-13 with the build (agent v0.155.0, hub v0.149.0, controller v0.310.0).
> Spike: `documentation/audits/poweroff-2026-10-13/`. Marks as in `01`: **[DESIGN]** a decision; **[FACT]** read in a
> named file or measured.
## 1. Why
**[FACT]** Before this, nothing could turn a Felhom machine off: „Kiszolgáló újraindítása" restarted only the guest
(controller `HandleServerReboot` → agent `POST /guest/reboot` → `pct reboot`). A household moving house could only pull
the plug. **[FACT]** Measured on the N100 (spike Part A): a clean guest shutdown (12 s) and `systemctl poweroff` work;
the plug then reads 0.0 W; the hub stayed silent and „ONLINE" for 18 minutes, and by code alarms at 45 min
(`host_stale`), 90 min (`host_down`) and every 05:00 (`expected_*_missed`); the N100 did **not** start when the plug
came back (BIOS „last state", inferred), its power button did (dashboard in 42 s).
## 2. The path
| Step | Where | What |
|---|---|---|
| 1 | controller `settings_system.html` | card „A gép kikapcsolása"; the household types KIKAPCSOLÁS / TURN OFF |
| 2 | controller `POST /api/server/poweroff` (`internal/web/poweroff_handlers.go`) | dashboard login (`RequireAuth` + CSRF), POST only, the word again, the controller's own backup/restore/app-update check, the `host_poweroff` feature gate (MinAgent 0.155.0) |
| 3 | agent `POST /host/poweroff` (`internal/localapi/hostpoweroff.go`) | `withGuest` token scope; the guards (§3); the record (`internal/poweroff`) |
| 4 | agent → hub | a host report carrying `deliberate_off.requested_at`, waited for until the hub ACCEPTED it (≤ 60 s, `Loop.SetReportedHook`) |
| 5 | agent | `pct shutdown <vmid> --timeout 120`, then `systemctl poweroff` (two exact sudoers vectors, `FELHOM_POWEROFF`) |
| 6 | hub (`store/deliberateoff.go`) | `hosts.deliberate_off_at`; silence alarms held 14 days (§4) |
| 7 | the household | presses the machine's power button; the first report of the new boot has no `deliberate_off` → the hub clears it |
## 3. The guards (agent) — each refuses with a stable `reason`, each fails closed
| Guard | Reason | Test (`hostpoweroff_test.go`) |
|---|---|---|
| first 10 min after boot | `too_soon_after_boot` | `TooSoonAfterBoot` |
| one accepted request per boot (file keyed by `/proc/stat` btime) | `already_requested_this_boot` | `SecondRequestSameBootRefused`, `poweroff/state_test.go` |
| any other guest or VM running (`/proc`: `lxc-start -n N`, `kvm -id N`) | `other_guests_running` | `RefusedWhileOtherGuestsRun`, `RunningGuestsFromProc` |
| heavy-operation gate held (backup, restore test, OS step, sweep, trim) | `backup_running` / `restore_test_running` / `os_update_running` / `host_busy` | `RefusedDuringBackup` |
| backup job, controller swap, drive format, NAS verify, escrow ceremony, Proxmox vzdump | `backup_running` / `controller_update_running` / `drive_operation_running` / `escrow_ceremony_running` | `RefusedDuringVzdump`, `FailsClosedWithoutTaskReader` |
**„A Proxmox vzdump runs” (agent v0.156.1, R-1008):** a vzdump task for the guest in Proxmox's active list whose worker process still lives — Proxmox's own rule (the UPID's pid + pstart against `/proc/<pid>/stat`). After a power cut the dead task stays listed for ~30–60 s; it no longer counts. Doubt (an unparsable task, an unreadable `/proc`) still refuses. The same answer serves the start-up stale-lock pass, the guest-power watchdog and the controller supervisor (`stalelock_inflight_test.go`).
| after acceptance: the heavy gate is TAKEN and kept; before each step the jobs and other guests are read again — a job waits ≤ 15 min, another guest cancels at once, a job still running cancels | (log) | `JobStartedAfterAcceptance`, `OtherGuestStartedAfterAcceptance` |
**[DESIGN]** A cancelled power-off leaves the box on (gate released) — better a box that did not switch off than a
backup, update or format cut in the middle. **[DESIGN]** A failed guest shutdown does not stop the power-off: Proxmox's
own shutdown stops a still-running guest before the host goes down.
## 4. The hub's hold — **[DESIGN]** 14 days (operator default, `store.DeliberateOffHold`)
Held while the box is silent AND inside the hold — every alarm a switched-off box would raise, named from the code:
| Alarm | Where | How it is held |
|---|---|---|
| `host_stale`, `host_down` | `monitor/host_staleness.go` | state `off` |
| `node_stale`, `node_down` | `monitor/staleness.go` | state `deliberately_off` |
| `expected_backup_missed`, `expected_dbdump_missed` | `monitor/deadline.go` | skipped (state, and the host row) |
| `offsite_stale`, `offsite_escrow_pending` | `monitor/offsite.go` | customer skipped |
| `restore_test_stale` | `monitor/restoretest.go` | customer skipped |
| `os_update_stale`, `agent_behind`, `os_config_bundle_behind`, `os_reboot_needed`, `os_not_covered` | `osupdates/service.go` | host filtered |
| the kernel „tonight your box restarts" mail | `osupdates/kernel.go` | host filtered |
**Not held, on purpose:** `os_ring0_stalled` (`osupdates/service.go`, `ring0Hosts`) — it is about the FLEET's update
rollout, not about this box: a ring-0 box switched off stalls the rollout, and the operator should know.
After the hold, still silent: `host_down` + `node_down` once, with a text naming the household power-off. Back on inside
the hold: no `*_recovered` mail. Pages: KIKAPCSOLVA and „Kikapcsolva a háztartás által, T". A customer is held only
when EVERY host of it is held; a future `requested_at` (the box's clock) is cut to the hub's now. Tests:
`monitor/deliberate_off_test.go` (each with a control that alarms), `api/deliberate_off_test.go`,
`web/deliberate_off_view_test.go`.
## 5. The threat it adds — attacker (e), a compromised controller (`13-threat-model.md`)
**[FACT]** `/host/poweroff` is the first local-API route whose effect reaches beyond the caller's own guest: a
compromised controller can now switch the whole machine off, and **nobody can switch it on remotely**. And it can
choose to: its power-off holds the hub's silence alarms for 14 days.
| Mitigation | Bound |
|---|---|
| one request per boot, none in the first 10 min after boot | no power-off loop; one outage per physical power-on |
| refused while another guest or VM runs | cannot take down a neighbour's guest (demo-hp, multi-guest hosts) |
| refused during backup/restore/update/drive jobs; re-checked before each step | cannot cut a job in the middle |
| the hold ends at 14 days; one machine of two does not hold the customer | the silence is bounded |
**Residual risk, named:** a compromised controller can switch a single-guest household box off once per power-on and
silence the hub for up to 14 days. The household sees a dark machine; the operator sees „Kikapcsolva a háztartás által"
— not an alarm. Accepted for a household product whose controller is already trusted with the household's data; the
alternative (an out-of-band confirmation) needs a second channel the box does not have.
## 6. Not done here
- The website FAQ line („to turn it off: Beállítások → Rendszer → A gép kikapcsolása") — a later website task (R-1000).
- The BIOS „after power loss" value of the N100 was not read (no monitor); the household text promises only the button.