diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbb45f..4d33043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## v0.310.0 — „A gép kikapcsolása": the household can turn the whole machine off (R-998); honest restart text (R-999) (2026-10-13) + +**MinAgent: 0.155.0** — the new route calls the agent's `POST /host/poweroff`. An older agent: the button is hidden and +one line says an update is needed (the `host_poweroff` feature row; a 404 gets the same answer). **Hub:** v0.149.0 holds +the silence alarms for 14 days; without it the box still powers off, and the operator gets the usual outage alarms. +Design: felhom.eu `documentation/architecture/14-host-power-off.md`. + +- **R-998 — the button.** Beállítások → Rendszer, after the restart cards: „A gép kikapcsolása" / "Turn the machine + off", „A gép teljesen kikapcsol. Az alkalmazásaid és a mentések leállnak. **Újra bekapcsolni csak otthon lehet: nyomd + meg a gép bekapcsológombját.**" A dialog where the household types KIKAPCSOLÁS / TURN OFF (the button stays off until + then; the server checks the word again, unaccented spelling accepted). `POST /api/server/poweroff`, dashboard login + only, POST only. The controller refuses itself during a backup, restore or app update; the agent's refusal reasons + are shown as household sentences (`poweroff.refused.*`, both languages). +- **R-999 — „Kiszolgáló újraindítása" said „the whole server".** It restarts the guest. Now „A Felhom újraindítása" / + "Restart Felhom": „Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva + marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. …" — the confirmation too. +- **R-983 (one of seven):** `/api/server/reboot` acted on a GET (CsrfProtect lets GET through) — now 405. +- Tests (`internal/web/poweroff_handlers_test.go`): the word is required (5 wrong bodies, 5 right ones), POST only, the + agent's and the controller's refusals shown and nothing asked of the agent, the card hidden for an old agent (and a 404 + answered as outdated), every refusal reason has a sentence in both languages, the restart route refuses a GET. + Red-proofed: each guard removed in turn convicted its test. The four `settings_system` i18n parity fixtures were + regenerated on purpose; the diff is exactly the new card, the dialog's type-to-confirm and the restart texts. + ## v0.309.0 — the simpler notification page: three group switches (R-388 slice 1); the fill warning in the household's language (R-521 F7) (2026-10-12) **MinAgent: 0.131.0** (unchanged — no agent call changed). **No wire change, no hub change:** the page still saves the diff --git a/controller/README.md b/controller/README.md index 87365a2..b33139a 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3282,6 +3282,21 @@ restart no longer signs the household out. Expiry is unchanged (7 days). Logout on disk too; rows written under another password are dropped at load; a revoking save that fails removes the file. Pinned by `internal/web/session_store_test.go` (`TestR35_*`). +### 8a. Turning the machine off — „A gép kikapcsolása" (v0.310.0, R-998) + +Beállítások → Rendszer, after the restart cards. Dashboard login only (`RequireAuth` + CSRF; family members' app logins +never reach the dashboard). The household types **KIKAPCSOLÁS** / **TURN OFF** (the server also takes the unaccented +spelling); `POST /api/server/poweroff {"confirm": "…"}` → the agent's `POST /host/poweroff` (agent ≥ 0.155.0, the +`host_poweroff` feature row). The agent shuts this guest down, then powers the host off; switching it on again needs the +machine's power button — the card says so. Refusals come back as one household sentence: a backup/restore/update the +controller sees itself, or the agent's reason (`too_soon_after_boot`, `already_requested_this_boot`, `backup_running`, +`other_guests_running`, …). An agent known to be older hides the button and shows one line. The hub holds its silence +alarms for 14 days (hub v0.149.0). Design: felhom.eu `documentation/architecture/14-host-power-off.md`. Tests: +`internal/web/poweroff_handlers_test.go`. + +**„A Felhom újraindítása"** (was „Kiszolgáló újraindítása", R-999) restarts the guest — the dashboard and the apps — +not the machine; its text now says so. `/api/server/reboot` is POST only (R-983, one of its seven). + ### 9. Central Hub Reporting #### Operator actions (`internal/report/opactions.go`, `09` §3 decision 185) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index b931415..9e98b7d 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -2156,6 +2156,9 @@ func main() { // Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance, // a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive. mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot)))) + // R-998: „A gép kikapcsolása" — the household turns the whole machine off (agent POST /host/poweroff, doc 14). + // Dashboard login only (RequireAuth): family members' app logins never reach the dashboard. + mux.Handle("/api/server/poweroff", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerPowerOff)))) // Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's // app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree. mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI)))) diff --git a/controller/internal/agentapi/features.go b/controller/internal/agentapi/features.go index cb46e9e..b6af6cc 100644 --- a/controller/internal/agentapi/features.go +++ b/controller/internal/agentapi/features.go @@ -83,6 +83,11 @@ const FeatureRecoveryFailureClass Feature = "recovery_failure_class" // for two months, and it heals itself when the agent updates. const FeatureRetainedRecoveryClass Feature = "retained_recovery_class" +// FeatureHostPowerOff is the household power-off (R-998, agent v0.155.0): POST /host/poweroff. A POST that switches +// the machine off cannot be probed, so the VERSION decides. The settings card hides only on a definite SupportNo; on +// Unknown it stays, and an old agent's 404 is answered with the update sentence. +const FeatureHostPowerOff Feature = "host_poweroff" + // SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never // refuses — the existing agent-error paths speak honestly when the agent is down). type SupportState int @@ -159,6 +164,10 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error { return errNoRecoveryProbe }, + // R-998: probing would switch the machine off. The row must exist (table-gap rule above). + FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error { + return errNoRecoveryProbe + }, } // errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be @@ -192,6 +201,9 @@ var featureMinAgent = map[Feature]string{ // screen must keep saying it cannot tell the two apart — which was true, and is what this gate // preserves for boxes that have not updated yet. FeatureRetainedRecoveryClass: "0.129.0", + + // R-998 — POST /host/poweroff, the household's „A gép kikapcsolása". + FeatureHostPowerOff: "0.155.0", } // MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no diff --git a/controller/internal/agentapi/hostpoweroff.go b/controller/internal/agentapi/hostpoweroff.go new file mode 100644 index 0000000..754039b --- /dev/null +++ b/controller/internal/agentapi/hostpoweroff.go @@ -0,0 +1,90 @@ +package agentapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/logx" +) + +// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ── +// +// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409 +// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household +// WHY (a backup runs, too soon after start, already asked…), so this call reads it. + +// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go). +const ( + PowerOffTooSoon = "too_soon_after_boot" + PowerOffAlreadyAsked = "already_requested_this_boot" + PowerOffBackup = "backup_running" + PowerOffRestoreTest = "restore_test_running" + PowerOffOSUpdate = "os_update_running" + PowerOffCtrlUpdate = "controller_update_running" + PowerOffDrive = "drive_operation_running" + PowerOffEscrow = "escrow_ceremony_running" + PowerOffBusy = "host_busy" + PowerOffOtherGuests = "other_guests_running" + PowerOffUnknown = "state_unknown" +) + +// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log. +type PowerOffRefused struct { + Reason string + Detail string +} + +func (e *PowerOffRefused) Error() string { + return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")" +} + +// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down +// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that +// predates the route. +func (c *Client) HostPowerOff(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}"))) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + start := time.Now() + resp, err := c.hc.Do(req) + if err != nil { + return fmt.Errorf("agentapi: POST /host/poweroff: %w", err) + } + defer resp.Body.Close() + c.noteAgentVersion(resp) + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds()) + switch resp.StatusCode { + case http.StatusAccepted, http.StatusOK: + return nil + case http.StatusConflict: + var env struct { + Data struct { + Reason string `json:"reason"` + Detail string `json:"detail"` + } `json:"data"` + } + if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" { + return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"} + } + return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail} + default: + return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode} + } +} + +// IsPowerOffRefused returns the refusal, if err is one. +func IsPowerOffRefused(err error) (*PowerOffRefused, bool) { + var r *PowerOffRefused + ok := errors.As(err, &r) + return r, ok +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index bb89c25..e19124d 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2031,13 +2031,13 @@ "settings_system.atmeretezes": "Resize", "settings_system.atmeretezes_2": "Resizing...", "settings_system.automatikus_frissites": "Automatic update", - "settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Restarts the whole server. Every app stops for a short time, and the dashboard is unreachable for about 30 seconds. Use it when the whole system needs a restart — otherwise “Restart controller” above is enough.", + "settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Restarts the Felhom part of the machine: the dashboard and all your apps. The machine itself stays on. Nothing can be reached for about 30 seconds; then everything comes back by itself. If only the dashboard is stuck, “Restart controller” above is enough.", "settings_system.az_ujrainditas_a_vartnal_tovabb": "The restart is taking longer than expected. Reload the page by hand.", "settings_system.az_uzemelteto_altal_beallitott_ertekek": "Values set by the operator. Ask the operator to change them.", "settings_system.beallitasok_rendszer": "Settings — System", "settings_system.biztonsagi_mentes": "Backup", "settings_system.biztosan_frissiti_a_controllert_n": "Update the controller?\\n\\nThe dashboard is unreachable for a short time during the update.", - "settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Restart the server? The apps and the dashboard become unreachable for about 30 seconds.", + "settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Restart Felhom? The apps and the dashboard become unreachable for about 30 seconds. The machine stays on.", "settings_system.biztosan_ujrainditja_a_vezerlot_a": "Restart the controller? It takes a few seconds, and the page is unreachable for a short time.", "settings_system.csokkentes": "Reduce", "settings_system.ellenorzes": "Checking...", @@ -2063,7 +2063,7 @@ "settings_system.jelenlegi_verzio": "Current version", "settings_system.kapcsolodasi_hiba": "Connection error", "settings_system.kesz": "Done.", - "settings_system.kiszolgalo_ujrainditasa": "Restart server", + "settings_system.kiszolgalo_ujrainditasa": "Restart Felhom", "settings_system.legujabb_verzio": "Latest version", "settings_system.megengedett_tartomany": "Allowed range", "settings_system.megerosites": "Confirm", @@ -2666,5 +2666,24 @@ "family_gate.confirm_reset": "Give a new password? The old one stops at once.", "family_gate.error": "That did not work. Try again.", "waiting.body": "This machine has just started for the first time, but it could not download its settings from the Felhom central service yet. It tries again every minute; once it succeeds, this page turns into your dashboard by itself. If you still see this in an hour, write to us: support@felhom.eu", - "waiting.title": "Felhom has not reached the central service yet" + "waiting.title": "Felhom has not reached the central service yet", + "settings_system.megerositeshez_ird_be": "To confirm, type:", + "settings_system.a_gep_kikapcsolasa": "Turn the machine off", + "settings_system.a_gep_teljesen_kikapcsol": "The machine switches off completely. Your apps and the backups stop.", + "settings_system.ujra_bekapcsolni_csak_otthon": "It can only be switched on again at home: press the power button of the machine.", + "settings_system.kikapcsolas_frissites_utan": "Turning the machine off needs an update of the software of the machine. It comes by itself; until then this button is hidden.", + "settings_system.kikapcsolas_szo": "TURN OFF", + "settings_system.kikapcsolas": "Turn off", + "settings_system.biztosan_kikapcsolod_a_gepet": "Turn the machine off? Felhom stops and the machine switches off. Nobody can switch it on again from afar: only at home, with the power button of the machine.", + "settings_system.a_gep_kikapcsol_folyamatban": "The machine is switching off. In a few minutes it stops completely, and this page will not answer any more. To switch it on again: press the power button of the machine.", + "poweroff.confirm_required": "Type the confirmation word: TURN OFF.", + "poweroff.agent_down": "The machine cannot be turned off now: the dashboard cannot reach the manager of the machine. Try again in a few minutes.", + "poweroff.agent_outdated": "Turning the machine off needs an update of the software of the machine. It comes by itself.", + "poweroff.refused.too_soon": "The machine has just started. You can turn it off in 10 minutes.", + "poweroff.refused.already": "The machine is already switching off.", + "poweroff.refused.backup": "A backup or a restore is running. Wait until it ends, then try again.", + "poweroff.refused.update": "An update is running. Wait until it ends, then try again.", + "poweroff.refused.drive": "The machine is working on a drive. Wait until it ends, then try again.", + "poweroff.refused.other_guests": "Another system runs on this machine, so it cannot be turned off from here.", + "poweroff.refused.generic": "The machine cannot turn itself off safely right now. Try again in a few minutes." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index ccc14be..e531332 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2019,13 +2019,13 @@ "settings_system.atmeretezes": "Átméretezés", "settings_system.atmeretezes_2": "Átméretezés...", "settings_system.automatikus_frissites": "Automatikus frissítés", - "settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Az egész kiszolgáló (szerver) újraindítása. Minden alkalmazás rövid időre leáll, és a vezérlőpult kb. 30 másodpercig nem elérhető. Akkor használd, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.", + "settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. Ha csak a vezérlőpult akad, a fenti „Vezérlő újraindítása” elég.", "settings_system.az_ujrainditas_a_vartnal_tovabb": "Az újraindítás a vártnál tovább tart. Töltsd újra az oldalt kézzel.", "settings_system.az_uzemelteto_altal_beallitott_ertekek": "Az üzemeltető által beállított értékek. Módosításhoz kérd az üzemeltetőt.", "settings_system.beallitasok_rendszer": "Beállítások — Rendszer", "settings_system.biztonsagi_mentes": "Biztonsági mentés", "settings_system.biztosan_frissiti_a_controllert_n": "Biztosan frissíti a controllert?\\n\\nA folyamat alatt a vezérlőpult rövid időre elérhetetlenné válik.", - "settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Biztosan újraindítod a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.", + "settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Biztosan újraindítod a Felhomot? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak. A gép bekapcsolva marad.", "settings_system.biztosan_ujrainditja_a_vezerlot_a": "Biztosan újraindítod a vezérlőt? A művelet néhány másodpercig tart, és a felület rövid időre elérhetetlen lesz.", "settings_system.csokkentes": "Csökkentés", "settings_system.ellenorzes": "Ellenőrzés...", @@ -2051,7 +2051,7 @@ "settings_system.jelenlegi_verzio": "Jelenlegi verzió", "settings_system.kapcsolodasi_hiba": "Kapcsolódási hiba", "settings_system.kesz": "Kész.", - "settings_system.kiszolgalo_ujrainditasa": "Kiszolgáló újraindítása", + "settings_system.kiszolgalo_ujrainditasa": "A Felhom újraindítása", "settings_system.legujabb_verzio": "Legújabb verzió", "settings_system.megengedett_tartomany": "Megengedett tartomány", "settings_system.megerosites": "Megerősítés", @@ -2654,5 +2654,24 @@ "family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.", "family_gate.error": "Nem sikerült. Próbáld újra.", "waiting.body": "Ez a gép most indult el először, de még nem tudta letölteni a beállításait a Felhom központi rendszeréből. Percenként újrapróbálkozik; amint sikerül, ez az oldal magától a vezérlőpultra vált. Ha egy óra múlva is ezt látod, írj nekünk: support@felhom.eu", - "waiting.title": "A Felhom még nem érte el a központot" + "waiting.title": "A Felhom még nem érte el a központot", + "settings_system.megerositeshez_ird_be": "Megerősítéshez írd be:", + "settings_system.a_gep_kikapcsolasa": "A gép kikapcsolása", + "settings_system.a_gep_teljesen_kikapcsol": "A gép teljesen kikapcsol. Az alkalmazásaid és a mentések leállnak.", + "settings_system.ujra_bekapcsolni_csak_otthon": "Újra bekapcsolni csak otthon lehet: nyomd meg a gép bekapcsológombját.", + "settings_system.kikapcsolas_frissites_utan": "A gép kikapcsolásához a gép szoftverének frissítése kell. Ez magától megtörténik, addig ez a gomb nem látszik.", + "settings_system.kikapcsolas_szo": "KIKAPCSOLÁS", + "settings_system.kikapcsolas": "Kikapcsolás", + "settings_system.biztosan_kikapcsolod_a_gepet": "Biztosan kikapcsolod a gépet? A Felhom leáll, és a gép kikapcsol. Távolról senki nem tudja újra bekapcsolni: csak otthon, a gép bekapcsológombjával.", + "settings_system.a_gep_kikapcsol_folyamatban": "A gép kikapcsol. Néhány perc múlva teljesen leáll, és ez az oldal nem válaszol többé. Újra bekapcsolni: nyomd meg a gép bekapcsológombját.", + "poweroff.confirm_required": "Írd be a megerősítő szót: KIKAPCSOLÁS.", + "poweroff.agent_down": "A gépet most nem lehet kikapcsolni, mert a vezérlő nem éri el a gép kezelőjét. Próbáld újra néhány perc múlva.", + "poweroff.agent_outdated": "A gép kikapcsolásához a gép szoftverének frissítése kell. Ez magától megtörténik.", + "poweroff.refused.too_soon": "A gép most indult el. 10 perc múlva kapcsolhatod ki.", + "poweroff.refused.already": "A kikapcsolás már folyamatban van.", + "poweroff.refused.backup": "Most mentés vagy visszaállítás fut. Várd meg, amíg befejeződik, és próbáld újra.", + "poweroff.refused.update": "Most frissítés fut. Várd meg, amíg befejeződik, és próbáld újra.", + "poweroff.refused.drive": "A gép most egy meghajtón dolgozik. Várd meg, amíg befejeződik, és próbáld újra.", + "poweroff.refused.other_guests": "Ezen a gépen más rendszer is fut, ezért innen nem lehet kikapcsolni.", + "poweroff.refused.generic": "A gép most nem tudja biztonságosan kikapcsolni magát. Próbáld újra néhány perc múlva." } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index a4774db..29b4332 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -2346,6 +2346,7 @@ func (s *Server) systemPageData() map[string]interface{} { } // Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability. s.memoryCardData(data) + s.powerOffCardData(data) // R-998: hide „A gép kikapcsolása" only for a definitely-too-old agent // „Hálózat" card (R-66): where the box IS, live-computed per render and stored NOWHERE — the // guest holds its address by DHCP, so a stored copy eventually misdirects people (S-5); an diff --git a/controller/internal/web/i18n_cases_a_test.go b/controller/internal/web/i18n_cases_a_test.go index 1467306..47d30d0 100644 --- a/controller/internal/web/i18n_cases_a_test.go +++ b/controller/internal/web/i18n_cases_a_test.go @@ -213,6 +213,7 @@ func i18nCasesA() []i18nCase { d["NetLANAddress"], d["NetSMBName"], d["NetGateway"] = "192.168.1.10", "FELHOM", "192.168.1.1" d["MemorySupported"], d["MemoryReachable"] = true, true d["MemoryAllocatedMB"], d["MemoryUsageMB"], d["MemoryMinMB"], d["MemoryMaxMB"] = 4096, 2048, 2048, 8192 + d["PowerOffSupported"] = true // R-998: the power-off card with its button return d }}, {"settings_system_failed", "settings_system", func() map[string]interface{} { @@ -223,6 +224,7 @@ func i18nCasesA() []i18nCase { d["RegistryAnonymous"] = false d["LastUpdateState"] = map[string]interface{}{"Status": "failed", "Error": "pull failed"} d["MemorySupported"], d["MemoryReachable"], d["MemoryProcMB"] = true, false, 4096 + d["PowerOffSupported"] = true return d }}, {"settings_system_pending", "settings_system", func() map[string]interface{} { @@ -231,11 +233,13 @@ func i18nCasesA() []i18nCase { d["SelfUpdateEnabled"] = true d["LastUpdateState"] = map[string]interface{}{"Status": "pending"} d["MemorySupported"], d["MemoryReachable"], d["MemoryProcMB"] = true, false, 0 + d["PowerOffSupported"] = true return d }}, {"settings_system_nomemory", "settings_system", func() map[string]interface{} { d := i18nLayoutData("settings", "Rendszer beállítások") d["MemorySupported"] = false + d["PowerOffSupported"] = false // R-998: an old agent — the card's one-line note instead of the button return d }}, {"settings_security_full", "settings_security", func() map[string]interface{} { diff --git a/controller/internal/web/poweroff_handlers.go b/controller/internal/web/poweroff_handlers.go new file mode 100644 index 0000000..c57d6f1 --- /dev/null +++ b/controller/internal/web/poweroff_handlers.go @@ -0,0 +1,142 @@ +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}) +} diff --git a/controller/internal/web/poweroff_handlers_test.go b/controller/internal/web/poweroff_handlers_test.go new file mode 100644 index 0000000..fa76cb2 --- /dev/null +++ b/controller/internal/web/poweroff_handlers_test.go @@ -0,0 +1,144 @@ +package web + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" +) + +// R-998 — „A gép kikapcsolása". The consequence is asserted: was the agent asked to switch the machine off, or not? + +type fakePowerAgent struct { + support agentapi.SupportState + err error + calls int +} + +func (f *fakePowerAgent) HostPowerOff(context.Context) error { f.calls++; return f.err } +func (f *fakePowerAgent) Supports(context.Context, agentapi.Feature) agentapi.SupportState { + return f.support +} + +func powerServer(t *testing.T, a *fakePowerAgent) *Server { + t.Helper() + s := testPageServer(t) + s.powerOffAgentFn = func() (powerOffAgent, error) { return a, nil } + s.powerOffBusyFn = func() (bool, string) { return false, "" } + return s +} + +func postPowerOff(s *Server, method, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "/api/server/poweroff", strings.NewReader(body)) + w := httptest.NewRecorder() + s.HandleServerPowerOff(w, r) + return w +} + +// Without the typed word nothing is switched off; with it (either language, either spelling) the agent is asked once. +func TestPowerOff_ConfirmationWordRequired(t *testing.T) { + for _, body := range []string{``, `{}`, `{"confirm":""}`, `{"confirm":"igen"}`, `{"confirm":"KIKAPCS"}`} { + a := &fakePowerAgent{support: agentapi.SupportYes} + w := postPowerOff(powerServer(t, a), http.MethodPost, body) + if w.Code != http.StatusBadRequest || a.calls != 0 { + t.Fatalf("body %q: got %d, agent asked %d times — want 400 and no call", body, w.Code, a.calls) + } + if !strings.Contains(w.Body.String(), "KIKAPCSOLÁS") { + t.Fatalf("the refusal does not name the word: %s", w.Body.String()) + } + } + for _, word := range []string{"KIKAPCSOLÁS", "kikapcsolás", " KIKAPCSOLAS ", "TURN OFF", "turn off"} { + a := &fakePowerAgent{support: agentapi.SupportYes} + w := postPowerOff(powerServer(t, a), http.MethodPost, `{"confirm":"`+word+`"}`) + if w.Code != http.StatusAccepted || a.calls != 1 { + t.Fatalf("word %q: got %d (%s), calls %d — want 202 and one call", word, w.Code, w.Body.String(), a.calls) + } + } +} + +// POST only: a GET (a link, an image tag) must never switch the machine off. +func TestPowerOff_PostOnly(t *testing.T) { + a := &fakePowerAgent{support: agentapi.SupportYes} + w := postPowerOff(powerServer(t, a), http.MethodGet, `{"confirm":"KIKAPCSOLÁS"}`) + if w.Code != http.StatusMethodNotAllowed || a.calls != 0 { + t.Fatalf("GET: %d, calls %d", w.Code, a.calls) + } +} + +// The agent's refusal reaches the household as a sentence; the controller's own busy work refuses before the agent. +func TestPowerOff_RefusalsShown(t *testing.T) { + a := &fakePowerAgent{support: agentapi.SupportYes, err: &agentapi.PowerOffRefused{Reason: agentapi.PowerOffBackup}} + w := postPowerOff(powerServer(t, a), http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`) + if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "Most mentés vagy visszaállítás fut") || + !strings.Contains(w.Body.String(), `"code":"backup_running"`) { + t.Fatalf("agent refusal: %d %s", w.Code, w.Body.String()) + } + a2 := &fakePowerAgent{support: agentapi.SupportYes} + s := powerServer(t, a2) + s.powerOffBusyFn = func() (bool, string) { return true, "a backup or restore is running" } + if w := postPowerOff(s, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusConflict || a2.calls != 0 { + t.Fatalf("controller busy: %d, agent calls %d — want 409 and no call", w.Code, a2.calls) + } +} + +// An agent older than the route: the card is HIDDEN with a one-line note, and the route answers agent_outdated +// without asking the agent. An agent that 404s anyway (version unknown) gets the same answer. +func TestPowerOff_HiddenForOldAgent(t *testing.T) { + old := &fakePowerAgent{support: agentapi.SupportNo} + s := powerServer(t, old) + page := getPage(t, s, "/settings").Body.String() + if strings.Contains(page, `id="btn-poweroff"`) || !strings.Contains(page, "data-poweroff-unavailable") { + t.Fatal("an old agent: the power-off button must be hidden, with the note") + } + if w := postPowerOff(s, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusPreconditionFailed || old.calls != 0 { + t.Fatalf("old agent: %d, calls %d", w.Code, old.calls) + } + cur := &fakePowerAgent{support: agentapi.SupportYes} + page = getPage(t, powerServer(t, cur), "/settings").Body.String() + if !strings.Contains(page, `id="btn-poweroff"`) || !strings.Contains(page, "A gép kikapcsolása") || + !strings.Contains(page, "nyomd meg a gép bekapcsológombját") { + t.Fatal("a current agent: the card, its title and the power-button sentence must be on the page") + } + unknown := &fakePowerAgent{support: agentapi.SupportUnknown, err: &agentapi.StatusError{Method: "POST", Path: "/host/poweroff", Code: 404}} + if w := postPowerOff(powerServer(t, unknown), http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusPreconditionFailed { + t.Fatalf("404 from an unknown-version agent: %d, want 412", w.Code) + } +} + +// R-983 (one of seven): the restart route must not act on a GET. +func TestServerReboot_PostOnly(t *testing.T) { + s := testServer(t) + w := httptest.NewRecorder() + s.HandleServerReboot(w, httptest.NewRequest(http.MethodGet, "/api/server/reboot", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET /api/server/reboot = %d, want 405", w.Code) + } +} + +// Every agent refusal reason maps to a household sentence that exists in BOTH languages (an unknown reason → the +// generic one), and an unreachable agent answers with its own sentence and asks nobody to switch anything off. +func TestPowerOff_EveryReasonHasASentence(t *testing.T) { + s := testPageServer(t) + b := s.bundle() + for _, reason := range []string{agentapi.PowerOffTooSoon, agentapi.PowerOffAlreadyAsked, agentapi.PowerOffBackup, + agentapi.PowerOffRestoreTest, agentapi.PowerOffOSUpdate, agentapi.PowerOffCtrlUpdate, agentapi.PowerOffDrive, + agentapi.PowerOffEscrow, agentapi.PowerOffBusy, agentapi.PowerOffOtherGuests, agentapi.PowerOffUnknown, "a_reason_from_the_future"} { + key := powerOffRefusalKey(reason) + for _, lang := range []string{"hu", "en"} { + if got := b.Msg(lang, key); got == "" || got == key { + t.Errorf("reason %q → key %q has no %s text", reason, key, lang) + } + } + } + down := testPageServer(t) + down.powerOffAgentFn = func() (powerOffAgent, error) { return nil, errors.New("agent not configured") } + down.powerOffBusyFn = func() (bool, string) { return false, "" } + w := postPowerOff(down, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`) + if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), "nem éri el a gép kezelőjét") { + t.Fatalf("agent down: %d %s", w.Code, w.Body.String()) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 418e949..4594a7a 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -201,6 +201,10 @@ type Server struct { memFeatures agentapi.SupportCache memAgentFn func() (memAgent, error) + // R-998: test seams for the power-off route (nil in production → the real agent client / busy checks). + powerOffAgentFn func() (powerOffAgent, error) + powerOffBusyFn func() (bool, string) + // Asset syncer for Hub-managed assets (optional) assetsSyncer *assets.Syncer diff --git a/controller/internal/web/settings_split_test.go b/controller/internal/web/settings_split_test.go index fd8910d..a030bc1 100644 --- a/controller/internal/web/settings_split_test.go +++ b/controller/internal/web/settings_split_test.go @@ -61,7 +61,7 @@ func TestSettingsSplitPagesRender(t *testing.T) { mustNot []string }{ {"/settings", - []string{"Rendszer konfiguráció", "Verzió és frissítés", "Vezérlő újraindítása", "Kiszolgáló újraindítása"}, + []string{"Rendszer konfiguráció", "Verzió és frissítés", "Vezérlő újraindítása", "A Felhom újraindítása", "A gép kikapcsolása"}, []string{"Adattárolók", "Jelszó módosítás", "Értesítési szünet"}}, {"/settings/notifications", []string{"Beállítások — Értesítések"}, @@ -110,7 +110,7 @@ func TestSettingsSectionInventory(t *testing.T) { "Alkalmazás-email", "Vészhelyzeti információk", // renamed from the misspelled "Veszhelyzeti informaciok" "Vezérlő újraindítása", - "Kiszolgáló újraindítása", + "A Felhom újraindítása", // R-999: was „Kiszolgáló újraindítása" — it restarts the Felhom part, not the machine } var union strings.Builder for _, f := range []string{"settings_system.html", "settings_notifications.html", "settings_security.html", "storage.html", "storage_network.html"} { diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index e3debbb..1af5064 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -817,6 +817,12 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) { // drive-activation banner; in the intermediary-mount model a drive binds live, so no reboot is needed // to activate storage — this button is purely a full-server restart.) func (s *Server) HandleServerReboot(w http.ResponseWriter, r *http.Request) { + // R-983 (one of its seven): CsrfProtect lets a GET through, so a link could restart the guest. POST only. + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + writeDiskJSON(w, http.StatusMethodNotAllowed, false, "POST only", nil) + return + } agent, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) diff --git a/controller/internal/web/templates/settings_system.html b/controller/internal/web/templates/settings_system.html index 30172c6..bb15d3f 100644 --- a/controller/internal/web/templates/settings_system.html +++ b/controller/internal/web/templates/settings_system.html @@ -240,10 +240,15 @@ function openDialog(opts){ root.innerHTML='
' +'

'+escText(opts.title||'{{T "settings_system.megerosites"}}')+'

' +'

'+escText(opts.message||'')+'

' - +'
' + // R-998: typeToConfirm — the button stays off until the household types the word (the storage page's pattern). + +(opts.typeToConfirm?'
' + +'
':'') + +'
' +'
' +'
'; - document.getElementById('dialog-go').onclick=function(){ closeDialog(); if(opts.onConfirm) opts.onConfirm(); }; + var typed=document.getElementById('dialog-type'); + if(typed){ typed.oninput=function(){ document.getElementById('dialog-go').disabled=(this.value.trim().toUpperCase()!==opts.typeToConfirm.toUpperCase()); }; } + document.getElementById('dialog-go').onclick=function(){ var v=typed?typed.value:''; closeDialog(); if(opts.onConfirm) opts.onConfirm(v); }; document.getElementById('dialog-cancel').onclick=function(){ closeDialog(); if(opts.onCancel) opts.onCancel(); }; } @@ -395,6 +400,20 @@ pollUntilBack();
+ +
+

{{T "settings_system.a_gep_kikapcsolasa"}}

+ {{if .PowerOffSupported}} +

{{T "settings_system.a_gep_teljesen_kikapcsol"}}

+

{{T "settings_system.ujra_bekapcsolni_csak_otthon"}}

+
+ + {{else}} +

{{T "settings_system.kikapcsolas_frissites_utan"}}

+ {{end}} +
+