diff --git a/REPORT.md b/REPORT.md index a2519a6..fd44f17 100644 --- a/REPORT.md +++ b/REPORT.md @@ -89,3 +89,45 @@ Hub version is the `main.Version` ldflags var (`build.sh `), default `"dev" ### Repo state Branch: `main`. Verified `go build/vet/test ./...` green in `hub/` locally (go1.26) and on the build server (go1.26). + +--- + +## Hub slice-3 follow-ups (v0.7.1) — 2026-06-08 + +Validation follow-ups (hub half). Pushed to `main`; build/vet/test green locally (go1.26) and on +the build server. + +### §3 — `/host-report` rejects oversize with 413 (not silent truncation) +`handleHostReport` now reads `maxHostReportBytes+1` (const `4 << 20`, defined near +`defaultHostPollSeconds`) and returns **`413 Payload too large`** when exceeded, instead of relying +on `LimitReader` truncation (which could accept a truncated-but-valid JSON as a partial report, +dropping guests from the mirror). **Scope-frozen:** the controller `handleReport` 1 MiB read is +**unchanged** (diff touches only the host path); the small divergence is acceptable until cutover. +`TestHandleHostReport_OversizeRejected` now asserts 413. + +### §4 — cross-repo contract golden fixture (hub half) +- `hub/internal/api/testdata/host-report.golden.json` — a **byte-identical copy** of felhom-agent's + golden (verified by md5). +- `TestHostReport_GoldenContract` — mints a host, POSTs the golden through the **real** + `handleHostReport`, asserts 200 + denorm (`guest_total=2`, `guest_running=1`, + `cloudflared_status="active"`) + both guests upserted. Proves `hostReportPayload` still extracts + the contract from the real wire shape. + +**Caveat (called out):** the two golden files are a *duplicated* contract with no shared source of +truth. JSON can't hold a comment, so the mandatory "keep byte-identical" marker lives in each test +file's doc comment. When slices 5/6 add real `storage_targets`/`backups` fields, promote this to a +shared Go types module (the proper fix); this fixture is the bridge. + +### Versioning / scope +Recorded **v0.7.1** in `hub/CHANGELOG.md`. The hub version is the `main.Version` ldflags var +(`build.sh `, default `"dev"`) — there is no in-repo version constant to bump (the task's +pointer to `web/version.go` is the controller-image `VersionChecker`, unrelated); the image tag is +applied at build/deploy (ArgoCD), not in this task. No deploy performed. + +### Untouched (confirmed) +Controller path (`handleReport`/`reports`/`customer_configs`/`checkAuthCustomer`/existing checkers) +unchanged. The agent's proxmox client timeout was a "confirm" item — already bounded (30s default), +no change. + +### Repo state +Branch: `main`. Verified `go build/vet/test ./...` green in `hub/` locally (go1.26) and on the build server (go1.26). diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 60860fe..ec7cc66 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,13 @@ # Felhom Hub — Changelog +## v0.7.1 (2026-06-08) + +### Changed +- **`/host-report` rejects oversize bodies explicitly with 413** (`handler.go`) instead of silently truncating at the 4 MiB `LimitReader` cap. Reads one byte past `maxHostReportBytes` and returns `413 Payload too large` — a truncated-but-valid JSON could otherwise be accepted as a partial report (silently dropping guests from the mirror). The controller `handleReport` 1 MiB path is **unchanged** (frozen until slice-10 cutover). + +### Added +- **Cross-repo contract fixture** `hub/internal/api/testdata/host-report.golden.json` (byte-identical with felhom-agent's copy) + `TestHostReport_GoldenContract` — POSTs the golden through the real `handleHostReport` and asserts 200 + denorm (`guest_total`/`guest_running`/`cloudflared_status`) + both guests upserted, proving `hostReportPayload` still extracts the contract from the real shape. Duplicated contract (no shared types module yet); revisit at slices 5/6. + ## v0.7.0 (2026-06-08) ### Added — host-domain ingest (slice 3, additive; controller path untouched) diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index b0f1b56..ef9cbb8 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -226,6 +226,13 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { // per-host override UI yet — that is a later slice). const defaultHostPollSeconds = 900 +// maxHostReportBytes bounds a host-report body. Larger than the controller path's +// 1 MiB because host reports carry the full guest list + (later) storage/backup +// arrays. We read one byte past it and reject explicitly (413) rather than letting +// LimitReader silently truncate — a truncated-but-valid JSON would otherwise be +// accepted as a partial report, dropping guests from the mirror. +const maxHostReportBytes = 4 << 20 // 4 MiB + // hostReportPayload is the subset of the agent host-report (slice-3 contract, // §3 / agent spec §4) the hub needs for denorm + guest reality. Unknown fields // (storage_targets/backups/restore_tests/pbs_snapshots/audit_tail) are ignored, @@ -258,13 +265,15 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) { return } - // 4 MiB: host reports carry the full guest list + future storage/backup arrays; - // the controller path's 1 MiB is too tight here. - body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + body, err := io.ReadAll(io.LimitReader(r.Body, maxHostReportBytes+1)) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } + if len(body) > maxHostReportBytes { + http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge) + return + } var rep hostReportPayload if err := json.Unmarshal(body, &rep); err != nil || rep.HostID == "" { http.Error(w, "Invalid payload: host_id required", http.StatusBadRequest) diff --git a/hub/internal/api/host_test.go b/hub/internal/api/host_test.go index 91f8a28..6a09bd2 100644 --- a/hub/internal/api/host_test.go +++ b/hub/internal/api/host_test.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -152,8 +153,8 @@ func TestHandleHostReport_OversizeRejected(t *testing.T) { st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) big := `{"host_id":"h1","guests":[{"vmid":1,"name":"` + strings.Repeat("a", 5<<20) + `"}]}` rr := do(h, http.MethodPost, "/host-report", "HKEY", big) - if rr.Code != http.StatusBadRequest { - t.Errorf("oversize body status = %d, want 400", rr.Code) + if rr.Code != http.StatusRequestEntityTooLarge { + t.Errorf("oversize body status = %d, want 413", rr.Code) } } @@ -188,3 +189,44 @@ func TestAdminCreateHost(t *testing.T) { t.Errorf("round-trip host-report with minted key = %d, body=%s", rr2.Code, rr2.Body.String()) } } + +// TestHostReport_GoldenContract drives the real handler with the shared golden +// host-report and proves hostReportPayload still extracts what it needs from the +// real wire shape (denorm + guest upsert). +// +// testdata/host-report.golden.json MUST be kept byte-identical with felhom-agent's +// internal/hub/testdata/host-report.golden.json — it is a duplicated contract until +// a shared types module exists (revisit when slices 5/6 add real fields). +func TestHostReport_GoldenContract(t *testing.T) { + h, st, dbPath := newTestHandler(t) + st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}) + st.UpsertHost(&store.Host{HostID: "demo-host-01", CustomerID: "c1", APIKey: "HKEY"}) + + golden, err := os.ReadFile("testdata/host-report.golden.json") + if err != nil { + t.Fatal(err) + } + rr := do(h, http.MethodPost, "/host-report", "HKEY", string(golden)) + if rr.Code != 200 { + t.Fatalf("golden report status = %d, body=%s", rr.Code, rr.Body.String()) + } + + db, _ := sql.Open("sqlite", dbPath) + defer db.Close() + + var total, running int + var cf string + if err := db.QueryRow(`SELECT guest_total, guest_running, cloudflared_status FROM host_reports WHERE host_id='demo-host-01' ORDER BY id DESC LIMIT 1`). + Scan(&total, &running, &cf); err != nil { + t.Fatal(err) + } + if total != 2 || running != 1 || cf != "active" { + t.Errorf("denorm total=%d running=%d cloudflared=%q (want 2,1,active)", total, running, cf) + } + + var guestCount int + db.QueryRow(`SELECT COUNT(*) FROM guests WHERE host_id='demo-host-01'`).Scan(&guestCount) + if guestCount != 2 { + t.Errorf("guests upserted = %d, want 2", guestCount) + } +} diff --git a/hub/internal/api/testdata/host-report.golden.json b/hub/internal/api/testdata/host-report.golden.json new file mode 100644 index 0000000..e2a3066 --- /dev/null +++ b/hub/internal/api/testdata/host-report.golden.json @@ -0,0 +1,38 @@ +{ + "host_id": "demo-host-01", + "reported_at": "2026-06-08T12:00:00Z", + "agent_version": "0.3.1", + "host": { + "node": "demo-felhom", + "cpu_percent": 3.2, + "memory_total_bytes": 16777216000, + "memory_used_bytes": 4194304000, + "memory_percent": 25, + "disk_total_bytes": 152000000000, + "disk_used_bytes": 30000000000, + "disk_percent": 19.7, + "loadavg": ["0.10", "0.20", "0.15"], + "uptime_seconds": 86400 + }, + "guests": [ + { + "vmid": 100, + "name": "felhom-cust-acme", + "status": "running", + "controller_version": "", + "spec": { "cores": 2, "memory_bytes": 2147483648, "disk_bytes": 21474836480 } + }, + { + "vmid": 101, + "name": "felhom-cust-beta", + "status": "stopped", + "controller_version": "" + } + ], + "storage_targets": [], + "backups": [], + "restore_tests": [], + "pbs_snapshots": [], + "cloudflared": { "status": "active" }, + "audit_tail": [] +}