diff --git a/REPORT.md b/REPORT.md
index 150959f..70a0951 100644
--- a/REPORT.md
+++ b/REPORT.md
@@ -2,36 +2,44 @@
> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md).
-## TASK — offsite SLICE 4: soft-quota + health monitoring + freeze lever — 2026-07-10 — DEPLOYED
+## TASK — hub v0.42.0: remote "Debug mód" toggle on the customer config editor — 2026-07-10
-**Shipped (felhom.eu):** hub **v0.41.0** `fad5573` + bump `04826c9` — LIVE (Synced/Healthy, "Offsite
-checker initialized"). Pairs with controller **v0.109.1** (9201, healthy). The offsite arc's last build
-item; operator-free (Viktor ran nothing).
+**Scope:** hub only (`felhom.eu/hub`). Agent/controller unchanged — the controller already honours
+`Logging.Level=="debug"` (`isDebug()` → `/debug` menu + verbose log). Version 0.41.0 → **0.42.0**.
-**Hub changes:**
-- **`monitor.OffsiteChecker`** — sibling of StorageFillChecker over the controller report's new `offsite`
- object: fill warn/crit at 90/95% of `quota_gb` (quota 0 = dedicated, silent) + `offsite_stale` (warning)
- for enabled+**escrowed** targets with no run >48h/never — the silently-stuck detector (recently-FAILING
- runs are `backup_failed`'s signal; pending/disabled never alert — **red-proofed**: dropping the
- escrowed-only filter made the pending customer alert → FAIL). Nil-safe on pre-v0.109 reports;
- same-second latest-report ties processed once per sweep.
-- **Freeze lever:** `Provisioner.SetOffsiteFrozen` — flips ONLY `readonly` on the exactly-1 labelled
- sub-account (SSH preserved — tested; ambiguity refuses); confirm-gated **Freeze/Unfreeze** buttons next
- to Re-issue (shared only). **MANUAL only, never automatic** — freezing also blocks prune, the customer's
- only way down from over-quota.
+### Goal
+Let an operator enable the controller's debug mode **remotely, without SSH**, from the hub's customer
+config editor — the support workflow (today: Peti's box). Enabling on any specific customer is a Viktor
+decision/click; this task does not turn it on for anyone.
-**Live verification (free checks, real pipeline):** the controller deploy's re-apply exercised
-**key-auth-first live** (re-pinned, zero password consumed) and surfaced a real bug — the re-apply demoted
-the escrowed demo to pending + wiped runtime status → **fixed as controller v0.109.1** (re-apply preserves
-custody + status; red-proofed), demo repaired (manual confirm — truthful, same escrowed password — + a
-run). The next report carried the full offsite object (`escrowed`, 45 MB / 50 GB, fresh run); the
-**checker held it healthy (0 offsite_* events)**; the `/backups` usage bar renders
-"Tárhelykeret: 43.2 MB / 50 GB (0%)".
+### Why a form field, not raw-JSON injection
+`handleConfigUpdate` rebuilds `ConfigJSON` from the form on every save (`configs.go` → `buildConfigJSON`),
+so a foreign key poked straight into the stored JSON is dropped on the next save. The toggle is therefore
+a first-class **form field**, which by construction survives every save. The offsite descriptor survives
+independently, via the post-save provision-merge — untouched by this change.
-**NOT live-exercised (per the STOP):** the freeze (would write-lock the demo's working offsite) and the
-100% refusal (demo is at 0.08%) — unit red-proofs cover both.
+### Changes
+- `internal/web/configs.go` — `buildConfigJSON`: `debug_mode` checked → `"logging":{"level":"debug"}`;
+ unchecked → the `logging` key is omitted entirely (controller.yaml default stands; no needless `"info"`).
+- `internal/web/templates/config_form.html` — new collapsible **"Hibakeresési mód (fejlesztői)"** section
+ with the `debug_mode` checkbox + a Hungarian hint (restart-next-cycle + the `/debug` URL + "turn it off
+ after"). Render state parsed from `ConfigJSON`. No emoji, no BOM (consistent with the hub templates).
+- `internal/web/configs_debug_test.go` — new tests (below).
+- Untouched, as required: configgen, the offsite provision-merge, the controller. No generic raw-JSON editor.
-**The offsite arc is now build-complete:** provisioning (S1/S2) → hardening (F1–F5, key-auth-first, wipe)
-→ auto-confirm (S3) → quota + health (S4). **NEXT:** the Peti onboarding runbook (live-exercises
-auto-confirm + F4 + fresh-provision quota end-to-end), NEW-box restore round-trip → retire the old-box
-archive.
+### Tests (non-hollow) — all green
+- `TestBuildConfigJSON_DebugMode` — checked emits `logging.level=debug`; unchecked omits the `logging` key.
+- `TestConfigUpdate_DebugSurvivesRebuild_OffsiteUntouched` — full path through `handleConfigUpdate`:
+ (a) the debug key lands, (b) the offsite descriptor (`host/user/repo_path/host_fingerprint/quota_gb`) is
+ unchanged across save + idempotent re-provision, and the **red-proof** that a hand-injected foreign key
+ (`foo`) is gone after one save — the concrete proof of why the switch had to be a form field.
+- `TestConfigForm_DebugRenderState` — debug-on ConfigJSON renders the checkbox `checked`; plain renders it unchecked.
+- **Red-proof exercised:** with the `logging` block removed from `buildConfigJSON`, the survival + form tests fail.
+
+Green gate: `go build ./... && go vet ./... && go test ./...` — all pass.
+
+### Deploy + live check (operator-free)
+GitOps: build 0.42.0 on 180 → bump `manifests/hub.yaml` tag → ArgoCD sync → Synced/Healthy. Live proof on
+the **demo** customer (not Peti): edit → tick debug → save → the demo controller restarts next cycle →
+`/debug` serves → untick → save → `/debug` 404 again. See the hub CHANGELOG top entry + session notes for
+the live-validation outcome. Peti-box use (enabling on his config) is Viktor's click — not part of this task.
diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md
index f02005e..98ec1c6 100644
--- a/hub/CHANGELOG.md
+++ b/hub/CHANGELOG.md
@@ -1,5 +1,28 @@
# Felhom Hub — Changelog
+## v0.42.0 — Remote "Debug mód" toggle on the customer config editor (2026-07-10)
+
+Lets an operator flip the controller's debug mode (verbose log + the `/debug` menu, which the controller
+gates on `Logging.Level=="debug"` / `isDebug()`) **remotely, without SSH** — the support workflow (today:
+Peti's box). The config-version bump on save makes the controller re-pull + self-restart on its next
+report ACK, so the switch takes effect within a cycle, hands-free.
+
+- **Form field, not raw-JSON injection — on purpose.** `handleConfigUpdate` REBUILDS `ConfigJSON` from the
+ form on every save (`buildConfigJSON`), so any foreign key injected straight into the stored JSON is
+ dropped on the next save. The toggle is therefore a real form field, which by definition survives every
+ save. (The offsite descriptor survives via its own separate provision-merge, untouched by this.)
+- **`buildConfigJSON`** (`internal/web/configs.go`): `debug_mode` checked → emits `"logging":{"level":"debug"}`;
+ unchecked → the `logging` key is OMITTED entirely (the generated `controller.yaml` default stands — no
+ needless `"info"`).
+- **Config form** (`templates/config_form.html`): new collapsible **"Hibakeresési mód (fejlesztői)"** section
+ with the `debug_mode` checkbox; render state parsed back from `ConfigJSON` (`logging.level=="debug"` → checked).
+ No change to the offsite/CF/git leg; no generic raw-JSON editor (deliberately — validated surfaces only).
+- **Tests (non-hollow, `configs_debug_test.go`):** form→JSON both ways (checked emits / unchecked omits);
+ full-path **survival test** through `handleConfigUpdate` proving the debug key lands, the offsite descriptor
+ is byte-for-byte unchanged across save+re-provision, and the **red-proof** that a hand-injected foreign key
+ is gone after one save (why the switch must be a form field); render state both ways. Red-proof exercised
+ (feature disabled → survival + form tests fail).
+
## v0.41.0 — SLICE 4: OffsiteChecker (fill + staleness) + operator freeze lever (2026-07-09)
The last build item of the offsite arc (pairs with controller v0.109.0's soft-quota gate + report object).
diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go
index 2a7ea45..058b069 100644
--- a/hub/internal/web/configs.go
+++ b/hub/internal/web/configs.go
@@ -955,6 +955,15 @@ func buildConfigJSON(r *http.Request) string {
overrides["git"] = git
}
+ // Logging (remote debug-mode toggle). The controller's /debug menu + verbose log key off
+ // Logging.Level=="debug" (controller isDebug()). This lives in the FORM — not raw-JSON injection —
+ // on purpose: handleConfigUpdate rebuilds ConfigJSON from the form on every save (buildConfigJSON),
+ // so a foreign key would be dropped on the next save. Checked → logging.level=debug; unchecked → the
+ // logging key is OMITTED entirely (the generated controller.yaml default stands — no needless "info").
+ if r.FormValue("debug_mode") != "" {
+ overrides["logging"] = map[string]interface{}{"level": "debug"}
+ }
+
data, _ := json.Marshal(overrides)
return string(data)
}
diff --git a/hub/internal/web/configs_debug_test.go b/hub/internal/web/configs_debug_test.go
new file mode 100644
index 0000000..0b55481
--- /dev/null
+++ b/hub/internal/web/configs_debug_test.go
@@ -0,0 +1,210 @@
+package web
+
+import (
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
+ "gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
+ "gitea.dooplex.hu/admin/felhom-hub/internal/store"
+)
+
+// postForm builds a POST request whose urlencoded body is parsed by r.FormValue/r.ParseForm,
+// exactly like the real edit submission.
+func postForm(target, body string) *http.Request {
+ r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return r
+}
+
+// The debug-mode toggle is a FORM field (not raw-JSON injection) so it survives the on-save rebuild.
+// This asserts the form→JSON leg: checked writes logging.level=debug; unchecked omits the key entirely
+// (so the generated controller.yaml default stands — no needless "info").
+func TestBuildConfigJSON_DebugMode(t *testing.T) {
+ // Checked → logging.level=debug present.
+ got := buildConfigJSON(postForm("/configs/x/edit", "debug_mode=on"))
+ var on map[string]interface{}
+ if err := json.Unmarshal([]byte(got), &on); err != nil {
+ t.Fatalf("checked: bad JSON %q: %v", got, err)
+ }
+ logging, ok := on["logging"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("checked: expected a logging object, got %q", got)
+ }
+ if logging["level"] != "debug" {
+ t.Fatalf("checked: expected logging.level=debug, got %v (%q)", logging["level"], got)
+ }
+
+ // Unchecked → NO logging key at all (not level=info).
+ got = buildConfigJSON(postForm("/configs/x/edit", "customer_name=Kov%C3%A1cs"))
+ var off map[string]interface{}
+ if err := json.Unmarshal([]byte(got), &off); err != nil {
+ t.Fatalf("unchecked: bad JSON %q: %v", got, err)
+ }
+ if _, present := off["logging"]; present {
+ t.Fatalf("unchecked: logging key must be OMITTED, got %q", got)
+ }
+}
+
+// The whole point of the form-level toggle: it survives the on-save ConfigJSON REBUILD, and coexists
+// with the offsite descriptor (which survives via the separate provision-merge). Companion RED-PROOF:
+// a foreign key injected straight into the stored ConfigJSON is GONE after one save — which is exactly
+// why the switch had to be a form field, not raw-JSON injection.
+func TestConfigUpdate_DebugSurvivesRebuild_OffsiteUntouched(t *testing.T) {
+ s, st := newTestServer(t)
+ s.SetOffsiteProvisioner(&offsite.Provisioner{
+ API: hetznerapi.NewFake(), Store: st, Scanner: webTestScanner{},
+ PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0),
+ })
+
+ const id = "cust-dbg"
+ if err := st.SaveCustomerConfig(&store.CustomerConfig{
+ CustomerID: id, CustomerName: "Kovács", Domain: "kovacs.felhom.eu", ConfigJSON: "{}",
+ }); err != nil {
+ t.Fatalf("seed customer: %v", err)
+ }
+
+ // 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet.
+ const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50"
+ w := httptest.NewRecorder()
+ s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("first save: expected 303, got %d: %s", w.Code, w.Body.String())
+ }
+ cfg, _ := st.GetCustomerConfig(id)
+ before := offsiteOf(t, cfg.ConfigJSON)
+ if before["host"] == nil || before["host"] == "" {
+ t.Fatalf("first save should have merged an offsite descriptor, got %q", cfg.ConfigJSON)
+ }
+
+ // 2) Inject a foreign key directly into the stored ConfigJSON — the raw-JSON path the task ruled out.
+ var obj map[string]json.RawMessage
+ json.Unmarshal([]byte(cfg.ConfigJSON), &obj)
+ obj["foo"] = json.RawMessage(`"bar"`)
+ inj, _ := json.Marshal(obj)
+ cfg.ConfigJSON = string(inj)
+ if err := st.SaveCustomerConfig(cfg); err != nil {
+ t.Fatalf("inject foreign key: %v", err)
+ }
+
+ // 3) Second save WITH offsite still enabled AND debug checked.
+ w = httptest.NewRecorder()
+ s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm+"&debug_mode=on"), id)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("second save: expected 303, got %d: %s", w.Code, w.Body.String())
+ }
+ cfg, _ = st.GetCustomerConfig(id)
+
+ var final map[string]interface{}
+ if err := json.Unmarshal([]byte(cfg.ConfigJSON), &final); err != nil {
+ t.Fatalf("final ConfigJSON invalid: %v", err)
+ }
+
+ // (a) debug logging key made it in.
+ logging, _ := final["logging"].(map[string]interface{})
+ if logging == nil || logging["level"] != "debug" {
+ t.Fatalf("debug toggle did not survive the save: logging=%v (%q)", final["logging"], cfg.ConfigJSON)
+ }
+
+ // (b) offsite descriptor UNCHANGED across the save+re-provision (idempotent label lookup).
+ after := offsiteOf(t, cfg.ConfigJSON)
+ for _, k := range []string{"host", "user", "repo_path", "host_fingerprint", "quota_gb"} {
+ if before[k] != after[k] {
+ t.Fatalf("offsite.%s changed across save: %v -> %v", k, before[k], after[k])
+ }
+ }
+
+ // RED-PROOF: the injected foreign key is GONE — buildConfigJSON rebuilt ConfigJSON from the form,
+ // so anything not represented as a form field is dropped. This is why the switch is a form field.
+ if _, present := final["foo"]; present {
+ t.Fatalf("foreign key survived the rebuild — the on-save rebuild premise is wrong: %q", cfg.ConfigJSON)
+ }
+}
+
+// offsiteOf extracts the offsite sub-object as a generic map for field-by-field comparison.
+func offsiteOf(t *testing.T, configJSON string) map[string]interface{} {
+ t.Helper()
+ var m map[string]interface{}
+ if err := json.Unmarshal([]byte(configJSON), &m); err != nil {
+ t.Fatalf("parse ConfigJSON: %v", err)
+ }
+ o, _ := m["offsite"].(map[string]interface{})
+ if o == nil {
+ t.Fatalf("no offsite object in %q", configJSON)
+ }
+ return o
+}
+
+// Render leg: a debug-on ConfigJSON draws the checkbox checked; a plain one draws it unchecked.
+func TestConfigForm_DebugRenderState(t *testing.T) {
+ s, _ := newTestServer(t)
+
+ render := func(configJSON string) string {
+ var overrides map[string]interface{}
+ json.Unmarshal([]byte(configJSON), &overrides)
+ data := struct {
+ IsNew bool
+ Config *store.CustomerConfig
+ Overrides map[string]interface{}
+ ActiveNav string
+ Error string
+ CSRFField string
+ }{
+ Config: &store.CustomerConfig{CustomerID: "c1"},
+ Overrides: overrides,
+ ActiveNav: "configs",
+ }
+ var b strings.Builder
+ if err := s.templates.ExecuteTemplate(&b, "config_form.html", data); err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ return b.String()
+ }
+
+ // debug on → the debug_mode checkbox carries `checked`.
+ out := render(`{"logging":{"level":"debug"}}`)
+ if !debugChecked(out) {
+ t.Fatalf("debug ConfigJSON should render the checkbox checked:\n%s", isolate(out))
+ }
+ // plain → not checked.
+ out = render(`{}`)
+ if debugChecked(out) {
+ t.Fatalf("plain ConfigJSON must render the checkbox UNchecked:\n%s", isolate(out))
+ }
+}
+
+// debugChecked reports whether the debug_mode checkbox input renders with the `checked` attribute.
+func debugChecked(html string) bool {
+ i := strings.Index(html, `name="debug_mode"`)
+ if i < 0 {
+ return false
+ }
+ // The input tag ends at the next '>'; `checked` (if present) sits before it.
+ end := strings.Index(html[i:], ">")
+ if end < 0 {
+ return false
+ }
+ return strings.Contains(html[i:i+end], "checked")
+}
+
+// isolate trims the rendered page down to the debug_mode input's neighborhood for readable failures.
+func isolate(html string) string {
+ i := strings.Index(html, `name="debug_mode"`)
+ if i < 0 {
+ return "(debug_mode input not found)"
+ }
+ start := i - 60
+ if start < 0 {
+ start = 0
+ }
+ end := i + 60
+ if end > len(html) {
+ end = len(html)
+ }
+ return html[start:end]
+}
diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html
index 8c3175a..10bf2c6 100644
--- a/hub/internal/web/templates/config_form.html
+++ b/hub/internal/web/templates/config_form.html
@@ -103,6 +103,18 @@
+
+
Hibakeresési mód (fejlesztői)
+
+
+
+ Bekapcsolva a vezérlő újraindul a következő ciklusban; a menü: https://felhom.<domain>/debug. Tesztelés után kapcsold ki.
+