controller: .fab browser download — the existing export pipeline staged under the data dir + a guarded streaming exit (estimate-first, io.Copy, post-stream cleanup, 1h TTL sweep); portability framing (decision 3); traversal guard red-proven, export→import round-trip + corrupt-bundle refusal unit-proven

Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
This commit is contained in:
2026-07-13 09:47:10 +02:00
parent aa967fbf69
commit 753cd83456
6 changed files with 607 additions and 0 deletions
@@ -0,0 +1,137 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// .fab download exit (v0.124.0 Part 3) — the containment guard, the post-stream cleanup and
// the TTL sweep. The guard is NON-NEGOTIABLE (§9): only an expected-shaped basename resolved
// strictly inside the staging exports dir is ever opened.
func downloadTestServer(t *testing.T) (*Server, string) {
t.Helper()
s := testServer(t)
s.cfg.Paths.DataDir = t.TempDir()
dir := s.fabDownloadDir()
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
return s, dir
}
func fetchDownload(s *Server, file string) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/export/download?file="+file, nil)
s.apiExportDownloadFetch(rr, req)
return rr
}
// Traversal guard: ../, absolute paths, separators and out-of-dir names must all be refused
// with NO file opened; a legit staged bundle streams byte-exactly and is removed afterwards.
// Red-proof: loosen the guard to raw prefix-matching → the `..` case FAILS (serves the decoy).
func TestFabDownload_TraversalGuardAndCleanup(t *testing.T) {
s, dir := downloadTestServer(t)
// A decoy OUTSIDE the staging dir that a traversal would reach.
decoy := filepath.Join(filepath.Dir(dir), "decoy.fab")
if err := os.WriteFile(decoy, []byte("DECOY"), 0644); err != nil {
t.Fatal(err)
}
// The legit staged bundle.
want := []byte("FAB-BUNDLE-BYTES")
if err := os.WriteFile(filepath.Join(dir, "actualbudget_20260713-080000.fab"), want, 0644); err != nil {
t.Fatal(err)
}
refused := []string{
"..%2Fdecoy.fab", // ../decoy.fab
"..%5Cdecoy.fab", // ..\decoy.fab
"%2Fetc%2Fpasswd", // absolute path
"sub%2Fx.fab", // separator
"..", // bare traversal
".hidden.fab", // not the exporter's naming (leading dot)
"x.txt", // not a .fab
}
for _, f := range refused {
if rr := fetchDownload(s, f); rr.Code != http.StatusBadRequest {
t.Errorf("file=%q: got %d, want 400", f, rr.Code)
}
}
if body, err := os.ReadFile(decoy); err != nil || string(body) != "DECOY" {
t.Fatal("the decoy outside the staging dir was touched")
}
// A legit-looking name that does not exist → 404 (never an open of anything else).
if rr := fetchDownload(s, "nosuch_20260713-080000.fab"); rr.Code != http.StatusNotFound {
t.Errorf("missing bundle: got %d, want 404", rr.Code)
}
// The legit bundle: streamed byte-exactly with attachment headers, then REMOVED.
rr := fetchDownload(s, "actualbudget_20260713-080000.fab")
if rr.Code != http.StatusOK {
t.Fatalf("legit download: got %d (%s)", rr.Code, rr.Body.String())
}
if rr.Body.String() != string(want) {
t.Fatalf("streamed bytes differ from the staged bundle")
}
if cd := rr.Header().Get("Content-Disposition"); !strings.Contains(cd, `attachment; filename="actualbudget_20260713-080000.fab"`) {
t.Errorf("Content-Disposition = %q", cd)
}
if _, err := os.Stat(filepath.Join(dir, "actualbudget_20260713-080000.fab")); !os.IsNotExist(err) {
t.Error("staged bundle must be removed after a successful stream")
}
}
// The download API inherits the post-claim auth: an unclaimed box answers 401 before any
// handler runs (asserted per §7C even though it rides the existing RequireAuth).
func TestFabDownload_UnauthenticatedRefused(t *testing.T) {
s, _, _ := claimTestServer(t)
mux := s.fullMux()
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/export/download?file=x_1.fab", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated download: got %d, want 401", rr.Code)
}
}
// TTL sweep: an aged bundle (and exporter temp) is removed; a fresh bundle and non-bundle
// files are kept. Time is injected — no clock dependency.
func TestFabDownload_TTLSweep(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
mk := func(name string, age time.Duration) {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(p, now.Add(-age), now.Add(-age)); err != nil {
t.Fatal(err)
}
}
mk("old_20260713-000000.fab", 2*time.Hour)
mk("stale.fab.tmp", 3*time.Hour)
mk("fresh_20260713-115500.fab", 5*time.Minute)
mk("unrelated.txt", 48*time.Hour)
if n := sweepFabDownloads(dir, now, time.Hour, nil); n != 2 {
t.Fatalf("sweep removed %d entries, want 2 (the aged bundle + temp)", n)
}
for name, wantGone := range map[string]bool{
"old_20260713-000000.fab": true,
"stale.fab.tmp": true,
"fresh_20260713-115500.fab": false,
"unrelated.txt": false,
} {
_, err := os.Stat(filepath.Join(dir, name))
gone := os.IsNotExist(err)
if gone != wantGone {
t.Errorf("%s: gone=%v want %v", name, gone, wantGone)
}
}
}
@@ -43,6 +43,15 @@ func (s *Server) ServeExportAPI(w http.ResponseWriter, r *http.Request) {
case path == "/api/export/status" && r.Method == http.MethodGet:
s.apiExportStatus(w, r)
// .fab browser download (v0.124.0): estimate → start (existing pipeline, staging dest) →
// guarded stream. Portability exit only — no scheduling, no status surface.
case path == "/api/export/download/estimate" && r.Method == http.MethodGet:
s.apiExportDownloadEstimate(w, r)
case path == "/api/export/download/start" && r.Method == http.MethodPost:
s.apiExportDownloadStart(w, r)
case path == "/api/export/download" && r.Method == http.MethodGet:
s.apiExportDownloadFetch(w, r)
// GET /api/export/bundles — scan for .fab files on all drives
case path == "/api/export/bundles" && r.Method == http.MethodGet:
s.apiExportBundles(w, r)
@@ -0,0 +1,189 @@
package web
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
)
// .fab browser download (v0.124.0, decision 3): PORTABILITY, not a backup tier — no scheduling,
// no status surface. The EXISTING async export pipeline produces the bundle (same producer as a
// drive export → byte-identical format); the only new mechanism is the dest (a dedicated staging
// dir under the controller data dir) and a guarded streaming exit. Bundles are removed after a
// successful stream and swept by a TTL cleanup (fabDownloadTTL) on startup and on each start.
const fabDownloadTTL = time.Hour
// fabDownloadRoot is the staging root; the exporter writes bundles into ExportDir(root).
func (s *Server) fabDownloadRoot() string {
return filepath.Join(s.cfg.Paths.DataDir, "fab-downloads")
}
func (s *Server) fabDownloadDir() string {
return appexport.ExportDir(s.fabDownloadRoot())
}
// fabDownloadNamePattern matches the exporter's own bundle naming (<stack>_<timestamp>.fab);
// a single safe segment, no separators, cannot start with a dot.
var fabDownloadNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*\.fab$`)
// validFabDownloadName is the containment guard's first half: only a bare, expected-shaped
// bundle FILENAME is accepted — no separators, no traversal, no absolute paths.
func validFabDownloadName(name string) bool {
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return false
}
return fabDownloadNamePattern.MatchString(name)
}
// apiExportDownloadEstimate returns the size estimate for a download export (shown BEFORE
// starting — the pre-download honesty mechanism). GET /api/export/download/estimate?stack=X
func (s *Server) apiExportDownloadEstimate(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App export not available", http.StatusServiceUnavailable)
return
}
stackName := r.URL.Query().Get("stack")
if stackName == "" || !validStackName(stackName) {
jsonError(w, "Missing or invalid stack parameter", http.StatusBadRequest)
return
}
if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
est, err := s.appExporter.EstimateExport(stackName, s.fabDownloadRoot())
if err != nil {
s.logger.Printf("[ERROR] [web] download-export estimate failed for %s: %v", stackName, err)
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, map[string]interface{}{"ok": true, "data": est})
}
// apiExportDownloadStart starts the EXISTING async export with the staging dir as dest.
// POST /api/export/download/start {stack_name, password, stop_app}
func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App export not available", http.StatusServiceUnavailable)
return
}
var req struct {
StackName string `json:"stack_name"`
Password string `json:"password"`
StopApp bool `json:"stop_app"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.StackName == "" || !validStackName(req.StackName) {
jsonError(w, "Missing or invalid stack_name", http.StatusBadRequest)
return
}
// Opportunistic TTL sweep so an abandoned bundle never outlives fabDownloadTTL by much.
sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, s.logger)
if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
// A concurrent export/import gets the exporter's own busy answer (single-flight).
if err := s.appExporter.StartExport(appexport.ExportRequest{
StackName: req.StackName,
DestDrive: s.fabDownloadRoot(),
Password: req.Password,
StopApp: req.StopApp,
}); err != nil {
s.logger.Printf("[ERROR] [web] download-export start failed for %s: %v", req.StackName, err)
jsonError(w, err.Error(), http.StatusConflict)
return
}
s.logger.Printf("[INFO] [web] download-export started for %s (staging dir)", req.StackName)
jsonResponse(w, map[string]interface{}{"ok": true})
}
// apiExportDownloadFetch streams a produced bundle to the browser and removes it afterwards.
// GET /api/export/download?file=<basename>. Containment is NON-NEGOTIABLE: only an
// expected-shaped basename, resolved strictly inside the staging exports dir, is ever opened.
func (s *Server) apiExportDownloadFetch(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
if !validFabDownloadName(name) {
s.logger.Printf("[WARN] [web] download-export fetch refused: invalid bundle name")
jsonError(w, "Invalid bundle name", http.StatusBadRequest)
return
}
dir := filepath.Clean(s.fabDownloadDir())
path := filepath.Join(dir, name)
// Second half of the guard: the resolved path's parent must BE the staging dir.
if filepath.Dir(path) != dir {
s.logger.Printf("[WARN] [web] download-export fetch refused: path escapes the staging dir")
jsonError(w, "Invalid bundle name", http.StatusBadRequest)
return
}
f, err := os.Open(path)
if err != nil {
jsonError(w, "Bundle not found", http.StatusNotFound)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() {
jsonError(w, "Bundle not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
n, err := io.Copy(w, f) // streaming — never ReadAll
if err != nil {
// Mid-stream failure (client gone): keep the bundle for a retry; the TTL sweep owns it.
s.logger.Printf("[WARN] [web] download-export stream aborted after %d bytes: %v", n, err)
return
}
f.Close()
if rmErr := os.Remove(path); rmErr != nil {
s.logger.Printf("[WARN] [web] download-export cleanup failed (TTL sweep will retry): %v", rmErr)
}
s.logger.Printf("[INFO] [web] download-export streamed %d bytes and removed the staged bundle", n)
}
// sweepFabDownloads removes staged bundles (and exporter temp files) older than maxAge.
// Returns how many entries were removed. Pure function of (dir, now) — tests inject both.
func sweepFabDownloads(dir string, now time.Time, maxAge time.Duration, logger *log.Logger) int {
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
removed := 0
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".fab") && !strings.HasSuffix(name, ".tmp") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) > maxAge {
if err := os.Remove(filepath.Join(dir, name)); err == nil {
removed++
if logger != nil {
logger.Printf("[INFO] [web] download-export TTL sweep removed a staged bundle (age > %s)", maxAge)
}
}
}
}
return removed
}
+6
View File
@@ -157,6 +157,12 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
s.loadTemplates()
go s.cleanupSessions()
// .fab download staging (v0.124.0): sweep aged bundles left by a crash/abandoned download.
if cfg.Paths.DataDir != "" {
if n := sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, logger); n > 0 {
logger.Printf("[INFO] [web] download-export startup sweep removed %d staged bundle(s)", n)
}
}
// Drive-absent gate reconcile (intermediary-mount model): the periodic absent/return detector that
// replaced the retired slice-8C watchdog. Stops+blocks apps whose drive vanished, auto-restarts them
// when it returns. No-op when the agent is unreachable.
@@ -89,10 +89,99 @@
</div>
{{end}}
<!-- Hordozható mentéscsomag (.fab) — v0.124.0, decision 3: PORTABILITY, not a backup tier (no
scheduling, no status surface; point-in-time framing everywhere). The download reuses the
EXISTING export pipeline (same producer as a drive export → byte-identical bundle) staged
under the data dir, then streams through a guarded endpoint. Import stays drive-scan. -->
<div class="backup-section-card">
<h3>Hordozható mentéscsomag (.fab)</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Hordozható pillanatfelvétel — bárhol tárolhatod, és bármikor visszatöltheted egy meghajtóról. A folyamatos védelmet az 13. szintű mentés adja.</p>
{{if .OffboxApps}}
<div class="form-row" style="max-width:420px"><label>Jelszavas titkosítás (opcionális)</label>
<input type="password" id="fab-dl-password" class="form-input" autocomplete="new-password" placeholder="Üresen hagyva a csomag titkosítatlan">
</div>
<div class="storage-paths-list">
{{range .OffboxApps}}
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
<div class="storage-path-actions">
<button type="button" class="btn btn-xs btn-outline fab-dl-btn" data-stack="{{.Name}}" onclick="fabDownload(this)">Letöltés (.fab)</button>
</div>
</div>
</div>
{{end}}
</div>
<div class="schedule-actions" style="margin-top:.75rem">
<button type="button" class="btn btn-xs btn-outline" id="fab-dl-all" onclick="fabDownloadAll()">Összes letöltése (egyenként)</button>
<span class="form-hint" style="margin-left:.5rem">A csomagok egyenként készülnek és töltődnek le — pillanatfelvétel a mostani állapotról.</span>
</div>
<div id="fab-dl-status" class="form-hint" style="margin-top:.5rem"></div>
{{else}}
<p class="form-hint">Nincs telepített alkalmazás.</p>
{{end}}
</div>
{{end}}
<script>
{{template "restore_banner_js"}}
// .fab download flow (v0.124.0): estimate → inline confirm (size shown BEFORE starting) →
// start (the EXISTING async export, staging dest) → poll → browser GET (guarded stream; the
// server removes the staged bundle after the stream). The batch runs apps ONE AT A TIME.
var fabQueue = [];
function fabSetStatus(msg){ document.getElementById('fab-dl-status').textContent = msg; }
function fabPassword(){ var el = document.getElementById('fab-dl-password'); return el ? el.value : ''; }
function fabDownload(btn){
var stack = btn.getAttribute('data-stack');
fetch('/api/export/download/estimate?stack=' + encodeURIComponent(stack))
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba: ' + (j.error || 'a becslés sikertelen')); return; }
var size = (j.data && j.data.total_size_human) || '?';
felhomConfirm(btn, 'A csomag becsült mérete: ' + size + '. Elindítod a letöltést? (Pillanatfelvétel a mostani állapotról.)', function(){
fabStart(stack, null);
});
})
.catch(function(){ fabSetStatus('Hiba: a becslés nem érhető el.'); });
}
function fabStart(stack, next){
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba (' + stack + '): ' + (j.error || 'az export nem indult el')); if (next) next(); return; }
fabSetStatus('Csomag készítése: ' + stack + '…');
fabPoll(stack, next);
})
.catch(function(){ fabSetStatus('Hiba: az export nem indult el.'); if (next) next(); });
}
function fabPoll(stack, next){
var iv = setInterval(function(){
fetch('/api/export/status').then(function(r){ return r.json(); }).then(function(j){
if (!j || j.running || !j.done) return; // keep polling until the job reports done
clearInterval(iv);
if (j.error) { fabSetStatus('Hiba (' + stack + '): ' + j.error); if (next) next(); return; }
var base = (j.output_path || '').split('/').pop();
if (!base) { fabSetStatus('Hiba: a csomag útvonala hiányzik.'); if (next) next(); return; }
fabSetStatus('Letöltés: ' + base + (j.output_size ? ' (' + j.output_size + ')' : ''));
window.location.href = '/api/export/download?file=' + encodeURIComponent(base);
if (next) setTimeout(next, 3000); // let the stream begin before the next export starts
}).catch(function(){});
}, 2000);
}
function fabDownloadAll(){
var btns = document.querySelectorAll('.fab-dl-btn');
fabQueue = Array.prototype.map.call(btns, function(b){ return b.getAttribute('data-stack'); });
fabRunQueue();
}
function fabRunQueue(){
var stack = fabQueue.shift();
if (!stack) { fabSetStatus('Minden csomag elkészült.'); return; }
fabStart(stack, fabRunQueue);
}
// Restore section
var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek', 'szombat'];
function formatSnapshot(s) {