diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 7b453d7..efebf54 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -74,6 +74,7 @@ "app_export.titkositasi_jelszo": "Encryption password", "app_export.valassz_tarolot": "Choose a storage...", "app_export.valaszthato_tartalom": "Optional content:", + "app_export.network_drive_needs_password": "A bundle can go onto a network drive (NAS) only with a password, because it also holds the passwords of the app. Set a password, or choose an attached drive.", "app_import.a_feltoltes_megszakadt_ellenorizze_a": "The upload stopped — check the connection, then try again.", "app_import.adatbazis": "Database:", "app_import.adatok": "Data:", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index e31c607..dc70286 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -70,6 +70,7 @@ "app_export.titkositasi_jelszo": "Titkosítási jelszó", "app_export.valassz_tarolot": "Válassz tárolót...", "app_export.valaszthato_tartalom": "Választható tartalom:", + "app_export.network_drive_needs_password": "Hálózati tárhelyre (NAS) csak jelszóval védett csomagot menthetsz, mert a csomag az alkalmazás jelszavait is tartalmazza. Adj meg jelszót, vagy válassz csatlakoztatott meghajtót.", "app_import.a_feltoltes_megszakadt_ellenorizze_a": "A feltöltés megszakadt — ellenőrizze a kapcsolatot, majd próbálja újra.", "app_import.adatbazis": "Adatbázis:", "app_import.adatok": "Adatok:", diff --git a/controller/internal/web/handler_export.go b/controller/internal/web/handler_export.go index 83ceaff..6b2b2ee 100644 --- a/controller/internal/web/handler_export.go +++ b/controller/internal/web/handler_export.go @@ -204,6 +204,18 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) { return } + // R-126 (operator ruling 2026-10-05, 09 §3 decision 128): a `.fab` carries the app's secrets in + // plaintext unless a bundle password is set, and a network drive is reachable by every device on + // the household's LAN. So an export WITHOUT a password to any network drive is refused; with a + // password it proceeds. The destination is already known to be registered (isValidDrivePath), so + // IsNetworkStoragePath classifies it by its Kind — the only discriminator (see RefuseAsAppNamespace). + // Pinned by TestExportStart_NetworkDriveNeedsPassword. + if req.Password == "" && s.settings.IsNetworkStoragePath(req.DestDrive) { + s.logger.Printf("[INFO] [web] apiExportStart: refused — no password for network drive %q (stack=%s)", req.DestDrive, req.StackName) + jsonError(w, s.msg(r, "app_export.network_drive_needs_password"), http.StatusBadRequest) + return + } + err := s.appExporter.StartExport(appexport.ExportRequest{ StackName: req.StackName, DestDrive: req.DestDrive, diff --git a/controller/internal/web/r126_export_network_test.go b/controller/internal/web/r126_export_network_test.go new file mode 100644 index 0000000..191a18c --- /dev/null +++ b/controller/internal/web/r126_export_network_test.go @@ -0,0 +1,105 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-126 (operator ruling 2026-10-05, 09 §3 decision 128): an export WITHOUT a bundle password to a +// network drive is refused; WITH a password it runs; a local drive without a password is unchanged. +// The assertions are the consequence — whether a .fab lands on the destination — not only the status. +func TestExportStart_NetworkDriveNeedsPassword(t *testing.T) { + build := func(t *testing.T, kind string) (*Server, *appexport.Exporter, string) { + s := testServer(t) + s.cfg.Paths.DataDir = t.TempDir() + drive := t.TempDir() + stackDir := t.TempDir() + os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0644) + fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK") + prov := &fabWebProvider{stackDir: stackDir, stacksDir: t.TempDir(), hddPath: drive, + binds: []appbackup.ClassifiedBind{ + {ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/books"}, Class: appbackup.ClassMandatory}, + }} + e := appexport.NewExporter(prov, s.logger, "test") + s.appExporter = e + if err := s.settings.AddStoragePath(settings.StoragePath{Path: drive, Label: "d", Kind: kind, Schedulable: true}); err != nil { + t.Fatal(err) + } + if got := s.settings.IsNetworkStoragePath(drive); got != (kind == settings.StorageKindNetwork) { + t.Fatalf("fixture: IsNetworkStoragePath(%q)=%v for kind %q", drive, got, kind) + } + return s, e, drive + } + start := func(s *Server, drive, password string) (*httptest.ResponseRecorder, map[string]interface{}) { + body, _ := json.Marshal(map[string]interface{}{"stack_name": "calibre-web", "dest_drive": drive, "password": password}) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/export/start", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + s.apiExportStart(rr, req) + var resp map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &resp) + return rr, resp + } + fabCount := func(dir string) int { + n := 0 + filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err == nil && strings.HasSuffix(p, ".fab") { + n++ + } + return nil + }) + return n + } + + t.Run("network drive, no password: refused, nothing written", func(t *testing.T) { + s, e, drive := build(t, settings.StorageKindNetwork) + rr, resp := start(s, drive, "") + if rr.Code != http.StatusBadRequest || resp["ok"] != false { + t.Fatalf("want 400 ok=false, got %d %s", rr.Code, rr.Body.String()) + } + msg, _ := resp["error"].(string) + // ASCII fragments of the Hungarian sentence (positive), and never the raw key (negative). + if !strings.Contains(msg, "(NAS)") || !strings.Contains(msg, "jelsz") || strings.Contains(msg, "app_export.") { + t.Errorf("refusal text is not the household sentence: %q", msg) + } + if j := e.GetActiveJob(); j != nil { + t.Errorf("an export job started despite the refusal: %v", j.Snapshot()) + } + if n := fabCount(drive); n != 0 { + t.Errorf("%d .fab file(s) landed on the network drive without a password", n) + } + }) + + t.Run("network drive, with password: runs", func(t *testing.T) { + s, e, drive := build(t, settings.StorageKindNetwork) + rr, resp := start(s, drive, "correct horse battery") + if rr.Code != http.StatusOK || resp["ok"] != true { + t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String()) + } + waitExportDone(t, e) + if n := fabCount(drive); n != 1 { + t.Errorf("want 1 .fab on the network drive, got %d", n) + } + }) + + t.Run("local drive, no password: unchanged", func(t *testing.T) { + s, e, drive := build(t, "") + rr, resp := start(s, drive, "") + if rr.Code != http.StatusOK || resp["ok"] != true { + t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String()) + } + waitExportDone(t, e) + if n := fabCount(drive); n != 1 { + t.Errorf("want 1 .fab on the local drive, got %d", n) + } + }) +} diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index f5fb644..243ad84 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -8,6 +8,7 @@ "banner.missed_backup.suggest": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.", "event.backup_catchup_done": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 109) -- the catch-up's NEW timeline line, never a Go literal; sent by Notifier.NotifyBackupCatchUp (wiring pinned by TestR871_CatchUpWiring).", "badge.lifecycle.abandoned": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "app_export.network_drive_needs_password": "BORN AS A KEY (R-126, `09` decision 128) -- a NEW sentence, never a Go literal: the export refusal for a network drive without a bundle password. Pinned in Hungarian by TestExportStart_NetworkDriveNeedsPassword.", "badge.lifecycle.abandoned.title": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", "badge.update.ahead.title": "BORN AS A KEY, v0.260.0 (R-524) -- a NEW sentence, never a Go literal, so there is nothing in the base capture to measure it against. Pinned in both languages by TestUpdateBadgeFollowsTheLanguage.", "badge.update.behind": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",