Files
felhom-controller/REPORT.md
T

6.5 KiB
Raw Blame History

REPORT — v0.128.0: chunked browser .fab upload on /import (tunnel-proof)

Date: 2026-07-13 · Version: controller v0.128.0 (baseline v0.127.3 59e4ca7 reconfirmed against live Gitea; local == origin/main) · Commit db16371 on main · MinAgent unchanged (no agent coupling — the feature is controller-local fs + HTTP). Implements TASK-fab-browser-upload (ruling F3 2026-07-13: chunked).

Step-0 probe — the Cloudflare edge cap, measured on the REAL tunnel (before any code)

Probe Result
POST 120 MiB (zeros) → https://felhom.demo-felhom.eu/step0-cap-probe HTTP 413 "Payload Too Large" from the edge (Server: cloudflare, CF-RAY a1aa78cd1f04324d-VIE, cloudflare-branded HTML body). Rejected on the declared Content-Length: only ~1.1 MB left the client, 0.41 s.
POST 80 MiB, same target HTTP 302 → /login?next=… — the controller's auth redirect, i.e. the origin was reached (edge passage PROVEN). 0.48 s; the origin answered before consuming the body (unauth redirect), so no full-upload throughput datum — the edge decision is header-based, which is the datum that matters.

Gotcha recorded: local DNS (192.168.0.250) overrides felhom.demo-felhom.eu to the LAN guest (192.168.0.139), and curl's default Expect: 100-continue lets the server answer before the body is sent — the honest probe needed --resolve onto Cloudflare's public IP (172.67.149.59) plus -H "Expect:". Outcome: cap confirmed at ~100 MB → uploadChunkBytes = 64 MiB (the spec's default), server-side per-request cap 96 MiB.

What shipped

  • controller/internal/web/handler_export_upload.go (new) — POST /api/export/upload/{init,chunk,finalize,abort} inside ServeExportAPI (inherits the main.go RequireAuth(CsrfProtect(...)) mount — nothing added at the mux). Single-flight slot (netstorage-job shape); init sanitizes to a [A-Za-z0-9._ -] base name + mandatory .fab, free-space gate (declared + 1 GiB margin, Hungarian error with both numbers); chunk requires offset == received (else 409 + received_bytes echo) and streams via io.Copy; finalize = exact declared size (else 422 + .part deleted) → fsync → atomic rename, collision → lowest-free "name (N).fab" computed from the ORIGINAL name (never (1)(1)); abort deletes the .part. Startup GC sweeps *.part-* on every registered drive (wired in main.go after SetAppExporter); 15-min idle timer aborts server-side. No client-side sha256 — deliberate, rationale in the file header (WebCrypto can't stream-hash multi-GB; .fab self-validates at import).
  • controller/internal/appexport/estimate.godiskFree exported as DiskFree (REUSE.md row added); web seam uploadDiskFree package var.
  • controller/internal/web/templates/app_import.html — upload zone above the bundle list (drag-and-drop + "Fájl kiválasztása", accept=".fab"), progress "Feltöltés: {pct}% ({done} / {total} GB)" + "Megszakítás", spec'd Hungarian error strings; File.slice sequential loop with one retry per chunk re-synced from the 409 echo; on success the page reloads (the EXISTING scan renders the row — no second lister). Copy-by-hand hint now names BOTH paths. Gates green: template_id, emoji, native_confirm, mojibake.

Tests (all green; full module go build && go vet && go test ./... = 23 packages ok)

Scenario Test Result
A happy path (3 chunks byte-exact, existing scan lists it, no .part residue) TestFabUpload_HappyPath PASS
B offset replay → 409 + echo, disk unchanged, re-sync continues TestFabUpload_OffsetMismatchRejected PASS
C traversal neutered inside exports; ext/charset/hidden refused; oversize (faked statfs) → Hungarian msg with 3.0/2.0 GB; 2nd init → 409 TestFabUpload_SecurityGates PASS
C route wrap (unauth POST → 401 on all four paths via the main.go-shaped mount) TestFabUpload_MountInheritsAuth PASS
D finalize size mismatch → 422, .part deleted, slot freed TestFabUpload_FinalizeSizeMismatch PASS
E startup GC removes stray .part, keeps real bundles; idle-expiry deletes + frees slot TestFabUpload_GCAndIdleTimeout PASS
F collision → app (1).fab, re-run → app (2).fab, original bytes untouched TestFabUpload_CollisionLowestFreeSuffix PASS
abort deletes .part + frees slot TestFabUpload_Abort PASS

Red-proofs (§10, all run + reverted, git diff clean):

  • C — sanitize bypassed (raw client filename used): TestFabUpload_SecurityGates FAILED with "sanitized upload must land inside the exports dir" (the file materialized at the traversal target {drive}/evil.fab).
  • B — offset check removed (append regardless): TestFabUpload_OffsetMismatchRejected FAILED with "replay: got 200, want 409".
  • F — naive overwrite (rename straight to the requested name): TestFabUpload_CollisionLowestFreeSuffix FAILED with first collision landed as "app.fab", want "app (1).fab".

Deploy + live verification

  • Image gitea.dooplex.hu/admin/felhom-controller:0.128.0 (sha256:4c943ea1…) built on 180.
  • Demo guest 9201: Up … (healthy) on 0.128.0; selfupdate log confirms "Current version 0.128.0 is up to date"; no ERROR/FATAL. Drill guest (nested PVE 192.168.0.152, guest 9201): Up … (healthy) on 0.128.0.
  • Route live-checked on the deployed box: unauthenticated POST /api/export/upload/init → 401 JSON both via LAN and through the real Cloudflare edge (--resolve onto 172.67.149.59) — the new path is deployed and auth+CSRF-wrapped on the customer path.

NOT live-validated (Viktor's ~5-minute leg)

The end-to-end multi-GB browser upload through the real tunnel needs a dashboard login (CC has no customer session). Suggested test: export a real app to .fab, download it via the browser, re-upload it on /import, import it — the full circle. Everything beneath the login was exercised: edge cap probed, endpoints tested AF, deployed route answers through the tunnel.

Observations

  • The per-drive target picker was deliberately left out (v1 = default drive only, per §12); if a customer keeps exports on a non-default drive, the upload still lands on the default one and the scan shows both — acceptable, but a picker is the natural follow-up.
  • controller/cmd/controller is matched by a gitignore rule — main.go changes need git add -f (it IS tracked; add refuses the path without -f).
  • Startup GC logs only when it removes something; the clean deploy logged nothing (expected).