v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s

This commit is contained in:
2026-10-16 22:46:00 +02:00
parent 00a0d91976
commit f75b3aa778
212 changed files with 2414 additions and 398 deletions
+6 -4
View File
@@ -12,10 +12,12 @@ import (
// TierOffsite capture set (recovery unit + MANDATORY userdata only) into the extra absolute paths
// appended to the app's restic snapshot, plus the Hungarian customer warnings for LOUD capture gaps.
//
// SP-3.4 is law here: restic does NOT error on a missing source path — it skips with a warning, exits 0,
// and silently writes a partial snapshot (0.14.0, and re-measured on 0.18.1 for R-1105, 2026-10-16). So a skipped/missing MANDATORY path is detected in
// THIS function (the structural-guard Skipped list + an os.Stat filter) and surfaced in BOTH the
// English log and the Hungarian LastWarning. A restic exit code proves nothing about a missing path.
// SP-3.4 is law here: restic skips a missing source path with a warning and still writes a partial snapshot.
// 0.14.0 and 0.18.1 then exit 0; 0.19.x exits 3 (restic #4467 — measured 2026-10-16, R-1107), which the run reads
// as that app's failure. Either way the filter below keeps a declared-but-absent path out of argv, so only a path
// that vanishes between the stat and restic reaches it — and on 0.19 that race is now loud instead of silent.
// So a skipped/missing MANDATORY path is detected in THIS function (the structural-guard Skipped list + an os.Stat
// filter) and surfaced in BOTH the English log and the Hungarian LastWarning — never left to restic's exit code.
// offboxBlocked records an app whose enlarged (userdata-carrying) push was refused by the pre-push
// quota gate. The unit-only push still proceeds (never a protection regression). estBytes is the
@@ -828,12 +828,31 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
}
}
// --- THE DATABASE PASSWORD COMES BACK WITH THE DATA (R-1106, `07` §7.4, decision 1A) ---------
// When the app keeps its own definition (same version), its app.yaml still holds the CURRENT install's database
// password — after a remove + reinstall, a freshly generated one that cannot open the data directory this restore
// is about to put back (measured 2026-10-16: Rallly crash-looped on auth, Grimmory was held). So when the snapshot
// replays a database service's data VOLUME, the snapshot's password is written into the app's settings before
// anything starts. A snapshot that replays only a logical dump leaves the current password alone — the dump goes
// into the current data directory, which the current password opens. Decided before the first mutation; a
// refusal touches nothing. (The other branch already gets this through unitRestoreEnv.)
var credOverlay map[string]string
if !defineFromSnapshot {
ov, oErr := m.offsiteCredentialOverlay(stack, scratchUnit, scratchCompose, man)
if oErr != nil {
return res, oErr
}
credOverlay = ov
}
definitionMoved := defineFromSnapshot || len(credOverlay) > 0
// --- THE LIVE DEFINITION, KEPT FOR A FAILED REPLAY (R-893, `09` §3 decision 192) -------------
// When the snapshot's definition is about to be written, keep the LIVE one first, so a failed
// replay below can write it back before the app is held. Taken before the first mutation; a
// failure here refuses with nothing touched — without it the failure branch could not be undone.
// R-1106: the same when only the database password is about to move.
var liveDef *liveDefinition
if defineFromSnapshot {
if definitionMoved {
ld, ldErr := m.captureLiveDefinition(stack)
if ldErr != nil {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: could not keep the live definition: %v — nothing was touched", stack, ldErr)
@@ -919,6 +938,14 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
}
return res, fmt.Errorf("restoring %s: writing the snapshot's definition failed: %w", stack, err)
}
} else if len(credOverlay) > 0 {
if err := m.stackProvider.(secretValueSetter).SetStackSecretValues(stack, credOverlay); err != nil {
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed password write also failed: %v", stack, sErr)
}
return res, fmt.Errorf("restoring %s: writing the backup's database password failed: %w", stack, err)
}
m.logger.Printf("[INFO] [offbox] %s: the backup's database password(s) %v written into the app's settings before the data (R-1106)", stack, credNames(credOverlay))
}
copier := m.fullPlaceCopier()
for _, pl := range placements {
@@ -943,7 +970,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if cErr != nil {
// R-893 option C (security review 2026-10-08): once the snapshot's (possibly older) definition
// is written, a start would run that version on the live data — HOLD the app instead.
if defineFromSnapshot {
if definitionMoved {
m.holdAppAfterMixedRestore(stack, cErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -977,7 +1004,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// already be the snapshot's — HOLD the app rather than start it on a mix.
// R-893 option C (security review 2026-10-08): a volume already replaced, or the snapshot's
// definition written → HOLD (the volume detail reaches the operator through the hold notice).
if defineFromSnapshot || nVols > 0 {
if definitionMoved || nVols > 0 {
m.holdAppAfterMixedRestore(stack, vErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -997,7 +1024,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if hasDB {
if err := m.stackProvider.StartStackServices(stack, dbServices); err != nil {
// R-893 option C (security review 2026-10-08): volumes or the definition already moved → HOLD.
if defineFromSnapshot || res.VolumesReplayed > 0 {
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, err, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -1044,7 +1071,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// Pinned by r893_mixed_restore_test.go; the plain case keeps TestR379_ScenarioA.
// Placed FILES alone do not hold (option C as ruled): that mix is what „put back exactly as it
// was" (option A, the next slice) removes.
if defineFromSnapshot || res.VolumesReplayed > 0 {
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, iErr, liveDef)
return res, util.MsgError("err.backup.db_restore_failed_held_mixed", stack)
}
@@ -12,13 +12,13 @@ import (
"time"
)
// R-1105: the off-site code was measured against restic 0.14.0; v0.323.0 ships 0.18.1. The fixtures in
// R-1105: the off-site code was measured against restic 0.14.0; v0.323.0 shipped 0.18.1, v0.324.0 ships 0.19.1 (R-1107). The fixtures in
// testdata/restic/<version>/ are REAL outputs of both official binaries, captured 2026-10-16 on one shared scratch
// repository (made by 0.14.0, so both versions read the same snapshot set; paths rewritten to /data/x). Each
// repository (made by 0.14.0, so every version reads the same snapshot set — recaptured for all three on 2026-10-16 20:10 UTC; paths rewritten to /data/x). Each
// assumption the code makes about restic's output is asserted for BOTH versions through the code's own functions —
// so a future restic that rewords one of them fails here, by name, instead of moving a failure to `unknown` on a
// customer's box (07-backup-architecture.md §6.7, §6.8).
var r1105Versions = []string{"0.14.0", "0.18.1"}
var r1105Versions = []string{"0.14.0", "0.18.1", "0.19.1"} // [0] is the measured baseline; v0.324.0 ships the last
func r1105Fixture(t *testing.T, version, name string) []byte {
t.Helper()
@@ -162,7 +162,7 @@ func TestR1105_TheJSONTheCodeReadsStillParses(t *testing.T) {
t.Errorf("restic %s: no status line carried the totals the progress bar shows", v)
}
}
if counts["0.14.0"] != counts["0.18.1"] {
if counts["0.14.0"] != counts["0.18.1"] || counts["0.14.0"] != counts["0.19.1"] {
t.Errorf("the two versions list a different number of snapshots of ONE repository: %v", counts)
}
}
@@ -213,40 +213,42 @@ func TestR1105_TheForgetPlanOnlyEverKeepsMore(t *testing.T) {
t.Errorf("restic %s: the guard refuses the honest plan: %s", v, refuse)
}
}
for id := range plans["0.18.1"] {
if !plans["0.14.0"][id] {
t.Errorf("0.18.1 removes snapshot %s, which 0.14.0 keeps — the new restic removes MORE", id)
for _, v := range r1105Versions[1:] { // every newer version against 0.14.0, the measured baseline
for id := range plans[v] {
if !plans["0.14.0"][id] {
t.Errorf("%s removes snapshot %s, which 0.14.0 keeps — the new restic removes MORE", v, id)
}
}
}
// Every snapshot only 0.14.0 removes is one 0.18.1 keeps as the „oldest" of a window with unused slots.
var groups []struct {
Reasons []struct {
Snapshot struct {
ID string `json:"id"`
} `json:"snapshot"`
Matches []string `json:"matches"`
} `json:"reasons"`
}
if err := json.Unmarshal(r1105Fixture(t, "0.18.1", "forget-dryrun.json"), &groups); err != nil {
t.Fatal(err)
}
why := map[string]string{}
for _, g := range groups {
for _, r := range g.Reasons {
why[r.Snapshot.ID] = strings.Join(r.Matches, ",")
// Every snapshot only 0.14.0 removes is one the newer version keeps as the „oldest" of a window with unused slots.
var groups []struct {
Reasons []struct {
Snapshot struct {
ID string `json:"id"`
} `json:"snapshot"`
Matches []string `json:"matches"`
} `json:"reasons"`
}
}
extra := 0
for id := range plans["0.14.0"] {
if plans["0.18.1"][id] {
continue
if err := json.Unmarshal(r1105Fixture(t, v, "forget-dryrun.json"), &groups); err != nil {
t.Fatal(err)
}
extra++
if !strings.Contains(why[id], "oldest") {
t.Errorf("0.18.1 keeps snapshot %s that 0.14.0 removes, for a reason other than “oldest”: %q", id, why[id])
why := map[string]string{}
for _, g := range groups {
for _, r := range g.Reasons {
why[r.Snapshot.ID] = strings.Join(r.Matches, ",")
}
}
extra := 0
for id := range plans["0.14.0"] {
if plans[v][id] {
continue
}
extra++
if !strings.Contains(why[id], "oldest") {
t.Errorf("%s keeps snapshot %s that 0.14.0 removes, for a reason other than “oldest”: %q", v, id, why[id])
}
}
if extra == 0 {
t.Errorf("%s: the fixture no longer shows the 0.17+ “oldest” keep — it no longer tests the difference it documents", v)
}
}
if extra == 0 {
t.Error("the fixture no longer shows the 0.17+ “oldest” keep — it no longer tests the difference it documents")
}
}
@@ -0,0 +1,301 @@
package backup
import (
"context"
"io"
"log"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// R-1106 (`07` §7.4, operator decision 1A): a restored database comes back with the password it was saved with.
const r1106Compose = `services:
app:
image: ghcr.io/x/app:1
environment:
- DATABASE_URL=postgresql://app:${DB_PASSWORD}@app-db:5432/app
app-db:
image: postgres:18-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- app_db_data:/var/lib/postgresql
- ./init:/docker-entrypoint-initdb.d
maria:
image: mariadb:11.4
environment:
MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-x}"
MARIADB_PASSWORD: literal-not-a-variable
MARIADB_DATABASE: app
volumes:
- type: volume
source: maria_data
target: /var/lib/mysql
cache:
image: redis:7-alpine
environment:
- REDIS_PASSWORD=${CACHE_PASSWORD}
`
// r1106OneDB is one Postgres fed by DB_PASSWORD — the Rallly shape.
const r1106OneDB = `services:
app:
image: ghcr.io/x/app:1
app-db:
image: postgres:18-alpine
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- app_db_data:/var/lib/postgresql
`
func TestR1106_DBCredentialVarsAndVolumes(t *testing.T) {
p := filepath.Join(t.TempDir(), "docker-compose.yml")
mustWrite(t, p, r1106Compose)
vars, err := DBCredentialVars(p)
if err != nil {
t.Fatal(err)
}
// list form, map form with a default; the literal yields nothing; redis is not a database.
if want := []string{"DB_PASSWORD", "DB_ROOT_PASSWORD"}; !reflect.DeepEqual(vars, want) {
t.Errorf("DBCredentialVars = %v, want %v", vars, want)
}
vols, err := DBDataVolumesFor(p)
if err != nil {
t.Fatal(err)
}
if want := []string{"app_db_data", "maria_data"}; !reflect.DeepEqual(vols, want) {
t.Errorf("DBDataVolumes = %v, want %v (binds are not volumes)", vols, want)
}
}
func TestR1106_PlanDBCredentials(t *testing.T) {
data := time.Date(2026, 10, 16, 18, 0, 0, 0, time.UTC)
before, after := data.Add(-24*time.Hour), data.Add(time.Hour)
names := []string{"DB_PASSWORD"}
cases := []struct {
name string
unit map[string]string
live map[string]string
installed time.Time
dataAt time.Time
wantErr bool
wantUnit string
}{
{"the unit's value wins over a reinstall's", map[string]string{"DB_PASSWORD": "old"}, map[string]string{"DB_PASSWORD": "new"}, after, data, false, "old"},
{"unit lacks it, install OLDER than the data → kept", nil, map[string]string{"DB_PASSWORD": "same"}, before, data, false, ""},
{"unit lacks it, install NEWER than the data → refused", nil, map[string]string{"DB_PASSWORD": "new"}, after, data, true, ""},
{"unit lacks it, install time unknown → refused", nil, map[string]string{"DB_PASSWORD": "new"}, time.Time{}, data, true, ""},
{"unit lacks it, data time unknown → refused", nil, map[string]string{"DB_PASSWORD": "new"}, before, time.Time{}, true, ""},
{"nowhere at all → refused", nil, nil, before, data, true, ""},
}
for _, c := range cases {
p, err := planDBCredentials(names, c.unit, c.live, c.installed, c.dataAt)
if (err != nil) != c.wantErr {
t.Errorf("%s: err = %v, wantErr %v", c.name, err, c.wantErr)
continue
}
if p.FromUnit["DB_PASSWORD"] != c.wantUnit {
t.Errorf("%s: FromUnit = %q, want %q", c.name, p.FromUnit["DB_PASSWORD"], c.wantUnit)
}
}
}
// r1106UnitRestoreFixture: a unit whose compose has a database fed by DB_PASSWORD, a live install deployed AFTER the
// unit's data. unitPW "" = the unit carries no DB_PASSWORD.
func r1106UnitRestoreFixture(t *testing.T, unitPW string, guest map[string]string) (*Manager, *fakeRecoveryProvider, *[]string) {
t.Helper()
tmp := t.TempDir()
drive := filepath.Join(tmp, "drive")
composeDir := RecoveryUnitComposePath(drive, "app")
mustWrite(t, filepath.Join(composeDir, "docker-compose.yml"), r1106OneDB)
env := "deployed: true\nenv:\n SUBDOMAIN: trips\n"
portable := []string{}
if unitPW != "" {
env += " DB_PASSWORD: " + unitPW + "\n"
portable = []string{"DB_PASSWORD"}
}
mustWrite(t, filepath.Join(composeDir, "app.yaml"), env)
man := &RecoveryManifest{SchemaVersion: 2, AppName: "app", ControllerVer: "v",
SecretEnvVars: []string{"DB_PASSWORD"}, PortableSecretEnvVars: portable,
CreatedAt: "2026-10-16T18:00:00Z", DumpsAt: "2026-10-16T18:00:00Z"}
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
t.Fatal(err)
}
live := filepath.Join(tmp, "live")
mustWrite(t, filepath.Join(live, "docker-compose.yml"), r1106OneDB)
mustWrite(t, filepath.Join(live, "app.yaml"), "deployed: true\ndeployed_at: \"2026-10-16T19:00:00Z\"\nenv: {}\n")
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: guest, info: RecoveryInfo{StackDir: live}}
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
var gen []string
m.generateSecret = func(_, envVar string) (string, bool) { gen = append(gen, envVar); return "GENERATED", true }
return m, fake, &gen
}
// The on-box restore after a reinstall: the backup's password is the one the app gets.
func TestR1106_UnitRestoreUsesTheBackupsDBPassword(t *testing.T) {
m, fake, gen := r1106UnitRestoreFixture(t, "old-pw", map[string]string{"DB_PASSWORD": "new-pw-from-reinstall"})
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
t.Fatalf("restore: %v", err)
}
if fake.gotEnv["DB_PASSWORD"] != "old-pw" {
t.Errorf("DB_PASSWORD = %q, want the backup's value", fake.gotEnv["DB_PASSWORD"])
}
if len(*gen) != 0 {
t.Errorf("a database password was generated: %v", *gen)
}
}
// A unit without the database password, restored over a NEWER install: refused before anything changes, and never
// a generated password (O4 used to make one — the R-127 (b) trap).
func TestR1106_UnitRestoreNeverGeneratesADBPassword(t *testing.T) {
for name, guest := range map[string]map[string]string{
"no value anywhere": nil,
"only the reinstall's new value": {"DB_PASSWORD": "new-pw-from-reinstall"},
} {
m, fake, gen := r1106UnitRestoreFixture(t, "", guest)
_, err := m.RestoreFromRecoveryUnit("app")
if err == nil || !strings.Contains(err.Error(), "adatbázis jelszavát") {
t.Errorf("%s: want the refusal sentence, got %v", name, err)
}
if fake.gotEnv != nil || fake.stopped {
t.Errorf("%s: the current install was touched (recreate %v, stopped %v)", name, fake.gotEnv != nil, fake.stopped)
}
if len(*gen) != 0 {
t.Errorf("%s: a database password was generated: %v", name, *gen)
}
}
}
// credProvider wraps the reconstitute fixture's provider with the live install's secrets, its app.yaml (for the
// install time) and the narrow writer, recording WHEN the writer ran relative to the other calls.
type credProvider struct {
*recordingProvider
live map[string]string
stackDir string
written map[string]string
}
func (p *credProvider) RecoverStackSecrets(_ string, names []string) map[string]string {
out := map[string]string{}
for _, n := range names {
if v, ok := p.live[n]; ok {
out[n] = v
}
}
return out
}
func (p *credProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return RecoveryInfo{StackDir: p.stackDir}, true
}
func (p *credProvider) SetStackSecretValues(_ string, values map[string]string) error {
p.written = values
p.calls = append(p.calls, "setsecrets")
return nil
}
// r1106ReconFixture: the reconstitute fixture with a database fed by DB_PASSWORD, a reinstall NEWER than the
// snapshot, the snapshot's unit carrying unitPW (or not), and the DB volume archive in the snapshot when withVolume.
func r1106ReconFixture(t *testing.T, unitPW string, withVolume bool) (*Manager, *credProvider) {
t.Helper()
m, prov, _ := reconFixture(t, "20261016T180000Z", "2026-10-16T18:00:00Z", pgDump(1))
compose := `services:
immich-server:
image: ghcr.io/immich-app/immich-server:v3.0.3
immich-postgres:
image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- immich_postgres_data:/var/lib/postgresql/data
`
mustWrite(t, prov.composePath, compose)
stackDir := filepath.Dir(prov.composePath)
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\ndeployed_at: \"2026-10-16T19:00:00Z\"\nenv: {}\n")
cp := &credProvider{recordingProvider: prov, live: map[string]string{"DB_PASSWORD": "new-pw-from-reinstall"}, stackDir: stackDir}
m.SetStackProvider(cp)
scratch, _, err := m.offboxRestoreScratchDir("immich")
if err != nil {
t.Fatal(err)
}
unit := findScratchUnitDir(scratch, "immich")
env := "deployed: true\nenv:\n SUBDOMAIN: photos\n"
portable := []string{}
if unitPW != "" {
env += " DB_PASSWORD: " + unitPW + "\n"
portable = []string{"DB_PASSWORD"}
}
mustWrite(t, filepath.Join(UnitComposeDir(unit), "app.yaml"), env)
man := &RecoveryManifest{SchemaVersion: 2, AppName: "immich", OffsiteRunID: "20261016T180000Z", DumpsAt: "2026-10-16T18:00:00Z",
SecretEnvVars: []string{"DB_PASSWORD"}, PortableSecretEnvVars: portable}
if err := writeManifest(filepath.Join(unit, "manifest.json"), man); err != nil {
t.Fatal(err)
}
if withVolume {
seedScratchVolumes(t, m, "immich", "immich_immich_postgres_data.tar")
}
m.volumeReplayFrom = func(_, dir string) (int, error) {
cp.calls = append(cp.calls, "volumes")
entries, _ := os.ReadDir(dir)
return len(entries), nil
}
return m, cp
}
// The drill's shape (Rallly, Grimmory, 2026-10-16): reinstall, then an off-site full restore that replays the DB
// volume. The backup's password must be in the app's settings BEFORE the database starts.
func TestR1106_OffsiteRestoreWritesTheBackupsDBPasswordBeforeTheDBStarts(t *testing.T) {
m, cp := r1106ReconFixture(t, "old-pw", true)
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false); err != nil {
t.Fatalf("ReconstituteFromOffsite: %v", err)
}
if cp.written["DB_PASSWORD"] != "old-pw" {
t.Fatalf("the backup's password was not written (got %v)", cp.written)
}
set, db := -1, -1
for i, c := range cp.calls {
if c == "setsecrets" && set < 0 {
set = i
}
if strings.HasPrefix(c, "startsvc:") && db < 0 {
db = i
}
}
if set < 0 || db < 0 || set > db {
t.Errorf("the password must be written before the database service starts; calls = %v", cp.calls)
}
}
// The unit carries no password and the install is newer than the snapshot: refused, nothing stopped or written.
func TestR1106_OffsiteRestoreRefusesWithoutTheDBPassword(t *testing.T) {
m, cp := r1106ReconFixture(t, "", true)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil || !strings.Contains(err.Error(), "adatbázis jelszavát") {
t.Fatalf("want the refusal sentence, got %v", err)
}
for _, c := range cp.calls {
if c == "stop" || c == "setsecrets" || c == "volumes" {
t.Errorf("the current install was touched: calls = %v", cp.calls)
break
}
}
}
// A snapshot that replays only a logical dump (no DB volume) must leave the current password alone: the dump goes
// into the current data directory, which only the current password opens.
func TestR1106_DumpOnlySnapshotKeepsTheCurrentPassword(t *testing.T) {
m, cp := r1106ReconFixture(t, "old-pw", false)
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false); err != nil {
t.Fatalf("ReconstituteFromOffsite: %v", err)
}
if cp.written != nil {
t.Errorf("a dump-only restore rewrote the password: %v", cp.written)
}
}
@@ -0,0 +1,194 @@
package backup
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-1106 — A RESTORED DATABASE COMES BACK WITH THE PASSWORD IT WAS SAVED WITH (`07` §7.4, operator decision 1A,
// 2026-10-16).
//
// A database engine reads its credential variables (POSTGRES_PASSWORD, MARIADB_ROOT_PASSWORD, …) only when it
// initialises an EMPTY data directory. Once a restore has put a data directory back, the only password that opens it
// is the one it was created with. Measured 2026-10-16 on demo-hp (the drill in `audits/debian13-restic-2026-10-16/`
// part2): Rallly and Grimmory removed, reinstalled (the reinstall generated new passwords), restored from the
// off-site copy — the data came back and neither app could open it: Rallly crash-looped `P1000`, Grimmory's replay
// and its rollback failed `ERROR 1045` and it was held.
//
// The rule: for every app.yaml variable that feeds a database service's credential (appbackup.DBCredentialVars), the
// value the backup unit carries (D5 — every `type: secret` travels) WINS over the current install's. When the unit
// carries none, the current install's value is used only when that install is OLDER than the data being restored
// (so it is the value the data was written with); otherwise the restore is REFUSED before anything changes. A
// database credential is never generated for restored data (O4 no longer reaches these names).
// errRestoreNoDBCredential is the refusal; the sentence is the bundle key's (HU + EN).
var errRestoreNoDBCredential = errors.New("restore refused: the backup does not carry the database password")
// dbCredPlan is the decision for one restore: the credential values to put into the app's settings (unit values
// that differ from — or are absent in — the current install), and every credential name considered.
type dbCredPlan struct {
Names []string // every variable that feeds a DB credential in the compose that will run
FromUnit map[string]string // name → the unit's value, for every name the unit carries
KeptLocal []string // names the unit lacks whose CURRENT value predates the data (kept)
}
// planDBCredentials decides, as a PURE function, which credential each DB variable gets. installedAt is when the
// current install was deployed (zero = unknown); dataAt is when the data being restored was captured (zero =
// unknown). An unknown time on either side never licenses the current value: unknown is not "older".
func planDBCredentials(names []string, unitSecrets, liveSecrets map[string]string, installedAt, dataAt time.Time) (dbCredPlan, error) {
p := dbCredPlan{Names: append([]string(nil), names...), FromUnit: map[string]string{}}
sort.Strings(p.Names)
var missing []string
for _, n := range p.Names {
if v := unitSecrets[n]; v != "" {
p.FromUnit[n] = v
continue
}
live := liveSecrets[n]
if live != "" && !installedAt.IsZero() && !dataAt.IsZero() && installedAt.Before(dataAt) {
p.KeptLocal = append(p.KeptLocal, n)
continue
}
missing = append(missing, n)
}
if len(missing) > 0 {
return p, fmt.Errorf("%w: %v (the current install is not older than the data, or no value at all)", errRestoreNoDBCredential, missing)
}
return p, nil
}
// installedAt reads when the app's CURRENT install was deployed (app.yaml `deployed_at`). Zero when unknown.
func (m *Manager) installedAt(stack string) time.Time {
info, ok := m.stackProvider.GetStackRecoveryInfo(stack)
if !ok || info.StackDir == "" {
return time.Time{}
}
cfg := stacks.LoadAppConfig(info.StackDir)
if cfg == nil || cfg.DeployedAt == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, cfg.DeployedAt)
if err != nil {
return time.Time{}
}
return t
}
// dbCredentialPlanFor reads the compose that will RUN (composePath), the unit's carried secrets and the live ones,
// and returns the plan — or the refusal, as the household's sentence, with the underlying reason logged. Nothing is
// written here.
func (m *Manager) dbCredentialPlanFor(stack, composePath string, unitSecrets map[string]string, dataAt time.Time) (dbCredPlan, error) {
if !fileExists(composePath) {
// A unit without its compose (never written by a current capture; old fixtures and hand-made units): read
// the LIVE definition instead — the variable NAMES do not change between installs of one template.
live, ok := m.stackProvider.GetStackComposePath(stack)
if !ok || live == "" || !fileExists(live) {
m.logger.Printf("[WARN] [backup] Restore %s: no compose to read the database password variables from — none checked", stack)
return dbCredPlan{FromUnit: map[string]string{}}, nil
}
composePath = live
}
names, err := DBCredentialVars(composePath)
if err != nil {
// "cannot tell" is not "no database password": refuse rather than restore blind.
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: cannot read which variables hold the database password (%s): %v", stack, filepath.Base(composePath), err)
return dbCredPlan{}, util.MsgError("err.backup.restore_no_db_password")
}
if len(names) == 0 {
return dbCredPlan{FromUnit: map[string]string{}}, nil
}
live := m.stackProvider.RecoverStackSecrets(stack, names)
plan, perr := planDBCredentials(names, unitSecrets, live, m.installedAt(stack), dataAt)
if perr != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s before any change: %v (R-1106)", stack, perr)
return plan, util.MsgError("err.backup.restore_no_db_password")
}
m.logger.Printf("[INFO] [backup] Restore %s: database password(s) %v come from the backup, %v stay as installed (older than the data) — values not logged (R-1106)",
stack, credNames(plan.FromUnit), plan.KeptLocal)
return plan, nil
}
func credNames(mp map[string]string) []string {
out := make([]string, 0, len(mp))
for k := range mp {
out = append(out, k)
}
sort.Strings(out)
return out
}
// secretValueSetter is the narrow write the off-site restore needs when the app keeps its own definition: put the
// named values into the app's settings (load-then-save, encrypted at rest) and nothing else. An optional interface
// so the many test fakes of StackProvider need no stub; a provider without it makes the restore REFUSE when a change
// is needed (fail-closed), never proceed with the wrong password.
type secretValueSetter interface {
SetStackSecretValues(name string, values map[string]string) error
}
// DBCredentialVars forwards appbackup.DBCredentialVars.
func DBCredentialVars(composePath string) ([]string, error) {
return appbackup.DBCredentialVars(composePath)
}
// offsiteCredentialOverlay (R-1106) decides, for an off-site restore that keeps the app's own definition, which
// database passwords to write into the app's settings: the snapshot's, for every DB credential whose value differs
// from the current install's — but ONLY when the snapshot replays a database service's data VOLUME (that data
// directory opens with its own password only). A dump-only snapshot changes nothing. Empty map = nothing to write.
func (m *Manager) offsiteCredentialOverlay(stack, scratchUnit, scratchCompose string, man *RecoveryManifest) (map[string]string, error) {
composeRun, ok := m.stackProvider.GetStackComposePath(stack)
if !ok || composeRun == "" {
return nil, nil // no live definition to read — the existing gates below decide (R-47's fail-closed refusal)
}
vols, err := DBDataVolumesFor(composeRun)
if err != nil {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: cannot read the database volumes of the live definition: %v", stack, err)
return nil, util.MsgError("err.backup.restore_no_db_password")
}
replaysDBVolume := false
for _, v := range vols {
if fileExists(filepath.Join(scratchUnit, "volume-dumps", stack+"_"+v+".tar")) {
replaysDBVolume = true
break
}
}
if !replaysDBVolume {
return nil, nil
}
_, unitSecrets := readUnitEnv(filepath.Join(scratchCompose, "app.yaml"), man.PortableSecretEnvVars)
plan, err := m.dbCredentialPlanFor(stack, composeRun, unitSecrets, manifestDataTime(man))
if err != nil {
return nil, err
}
live := m.stackProvider.RecoverStackSecrets(stack, plan.Names)
ov := map[string]string{}
for n, v := range plan.FromUnit {
if live[n] != v {
ov[n] = v
}
}
if len(ov) > 0 {
if _, ok := m.stackProvider.(secretValueSetter); !ok {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: the database password must change and this provider cannot write it", stack)
return nil, fmt.Errorf("restoring %s: the backup's database password cannot be written", stack)
}
}
return ov, nil
}
func fileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
// DBDataVolumesFor forwards appbackup.DBDataVolumes.
func DBDataVolumesFor(composePath string) ([]string, error) {
return appbackup.DBDataVolumes(composePath)
}
+33 -1
View File
@@ -511,6 +511,19 @@ func (m *Manager) unitRestoreEnv(stackName, composeDir string, manifest *Recover
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
return nil, missing, err
}
// R-1106 (`07` §7.4, decision 1A): the database password comes back WITH the data — the unit's value, or the
// current install's only when that install is older than the data; otherwise refused here, before any change.
dbPlan, perr := m.dbCredentialPlanFor(stackName, filepath.Join(composeDir, "docker-compose.yml"), unitSecrets, manifestDataTime(manifest))
if perr != nil {
return nil, missing, perr
}
for n, v := range dbPlan.FromUnit {
fullEnv[n] = v
}
dbCredSet := make(map[string]bool, len(dbPlan.Names))
for _, n := range dbPlan.Names {
dbCredSet[n] = true
}
// O4: a missing RESETTABLE secret used to redeploy blank (compose "Defaulting to a blank
// string" → exit 1). Generate a replacement via the deploy flow's generator instead —
// RecreateStackFromUnit persists fullEnv through SaveAppConfig, so the new value lands
@@ -534,7 +547,9 @@ func (m *Manager) unitRestoreEnv(stackName, composeDir string, manifest *Recover
}
var generated, unresolved []string
for _, name := range missing {
if !dataKeySet[name] && m.generateSecret != nil {
// R-1106: never a database password — a generated one cannot open the restored data directory.
// (dbCredentialPlanFor refused already if one was unrecoverable; this is the second line.)
if !dataKeySet[name] && !dbCredSet[name] && m.generateSecret != nil {
if v, ok := m.generateSecret(stackName, name); ok && v != "" {
fullEnv[name] = v
generated = append(generated, name)
@@ -554,3 +569,20 @@ func (m *Manager) unitRestoreEnv(stackName, composeDir string, manifest *Recover
}
return fullEnv, missing, nil
}
// manifestDataTime is when the unit's DATA was captured: the data stamp (v0.275.0+), else the dump leg's end, else the
// unit's own creation time. Zero when none parses — and zero never licenses keeping the current install's password.
func manifestDataTime(man *RecoveryManifest) time.Time {
if man == nil {
return time.Time{}
}
if t, ok := man.Data.DataTime(); ok {
return t
}
for _, s := range []string{man.DumpsAt, man.CreatedAt} {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
}
return time.Time{}
}
@@ -1,2 +1,2 @@
{"message_type":"status","percent_done":0,"total_files":1,"total_bytes":41943040}
{"message_type":"status","percent_done":0.6679942601232657,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":28017724,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":0.7110543744053397,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":29823797,"current_files":["/data/x/u/a/big.tar"]}
@@ -1,14 +1,14 @@
using temporary cache in /data/x/cache/restic-check-cache-279810929
using temporary cache in /data/x/cache/restic-check-cache-1162611374
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 49327794:
decrypting blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156 failed: ciphertext verification failed
error for tree 2dd49e74:
decrypting blob 2dd49e743c4d0b61ec5cfa2dc99f5446d5e7482635df00484aaa6a193aba98b8 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
Pack ID does not match, want fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72, got 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948
Pack ID does not match, want fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160, got d22a000a356e727dc3afaeef9408a189de77107618df88519e050153a5a653a8
[0:00] 100.00% 123 / 123 packs
Fatal: repository contains errors
@@ -1,4 +1,4 @@
using temporary cache in /data/x/cache/restic-check-cache-1995998052
using temporary cache in /data/x/cache/restic-check-cache-2557640014
create exclusive lock for repository
load indexes
check all packs
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
unable to create lock in backend: repository is already locked exclusively by PID 2773051 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:13 (1.310283004s ago)
storage ID 8cbd92f5
unable to create lock in backend: repository is already locked exclusively by PID 3688286 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:02:53 (1.105027717s ago)
storage ID e0199133
the `unlock` command can be used to remove stale locks
@@ -1,4 +1,4 @@
unable to create lock in backend: repository is already locked by PID 2772696 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:11 (1.294783283s ago)
storage ID a2cb4ce8
unable to create lock in backend: repository is already locked by PID 3687988 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:02:52 (1.102036609s ago)
storage ID fd3abc66
the `unlock` command can be used to remove stale locks
@@ -1 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
[{"time":"2026-10-16T02:30:00Z","parent":"27898187d9337190afb6154237f44817ccf2070d6be912c86af3c1ef55ed8e3d","tree":"b830b8165dc45c4b23086a81004548a5a52a00060028f0d274d8098dcc168b49","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"94fc37b9bfe97271d4a0b0feda4443a5e2934a3557108dd521d0bfb2f49423c8","short_id":"94fc37b9"}]
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"total_size":217738,"total_file_count":0,"total_blob_count":863,"snapshots_count":82}
{"total_size":217458,"total_file_count":0,"total_blob_count":863,"snapshots_count":82}
@@ -1,4 +1,4 @@
{"message_type":"status","percent_done":0.056467624048707366,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":2368425,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":0.5571281981541595,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":23367662,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":5.006787654339296e-7,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":21,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":0.5636983671744893,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":23643235,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":1,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":41943061}
{"message_type":"status","percent_done":1,"total_files":3,"files_done":3,"total_bytes":41943061,"bytes_done":41943061}
@@ -1,18 +1,18 @@
using temporary cache in /data/x/cache/restic-check-cache-3291579747
using temporary cache in /data/x/cache/restic-check-cache-1056767702
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 49327794:
decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed
error for tree 2dd49e74:
decrypting blob <tree/2dd49e74> from fd6de2a5 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
pack fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72 contains 2 errors: [blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156: decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed unexpected pack id 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948]
pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 contains 2 errors: [blob 2dd49e743c4d0b61ec5cfa2dc99f5446d5e7482635df00484aaa6a193aba98b8: decrypting blob <tree/2dd49e74> from fd6de2a5 failed: ciphertext verification failed unexpected pack id d22a000a356e727dc3afaeef9408a189de77107618df88519e050153a5a653a8]
[0:00] 100.00% 123 / 123 packs
The repository contains damaged pack files. These damaged files must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.
restic repair packs fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72
restic repair packs fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160
restic repair snapshots --forget
Damaged pack files can be caused by backend problems, hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!
@@ -1,4 +1,4 @@
using temporary cache in /data/x/cache/restic-check-cache-4211187088
using temporary cache in /data/x/cache/restic-check-cache-2213934293
create exclusive lock for repository
load indexes
check all packs
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked exclusively by PID 2775667 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:26 (1.312752495s ago)
storage ID be8a3f47
unable to create lock in backend: repository is already locked exclusively by PID 3690145 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:03 (1.111629809s ago)
storage ID 3cf3b3cf
the `unlock` command can be used to remove stale locks
@@ -1,5 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked by PID 2775299 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:25 (1.301138628s ago)
storage ID b41afa86
unable to create lock in backend: repository is already locked by PID 3689928 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:02 (1.097394824s ago)
storage ID 74d5af8c
the `unlock` command can be used to remove stale locks
@@ -1 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
[{"time":"2026-10-16T02:30:00Z","parent":"27898187d9337190afb6154237f44817ccf2070d6be912c86af3c1ef55ed8e3d","tree":"b830b8165dc45c4b23086a81004548a5a52a00060028f0d274d8098dcc168b49","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"94fc37b9bfe97271d4a0b0feda4443a5e2934a3557108dd521d0bfb2f49423c8","short_id":"94fc37b9"}]
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"total_size":217738,"total_uncompressed_size":345762,"compression_ratio":1.587972701136228,"compression_progress":100,"compression_space_saving":37.02662525089513,"total_blob_count":863,"snapshots_count":82}
{"total_size":217458,"total_uncompressed_size":345270,"compression_ratio":1.5877548768037966,"compression_progress":100,"compression_space_saving":37.017985924059424,"total_blob_count":863,"snapshots_count":82}
@@ -0,0 +1 @@
{"message_type":"status","percent_done":0.3130332809996867,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":13129574,"current_files":["/data/x/u/a/big.tar"]}
@@ -0,0 +1,19 @@
using temporary cache in /data/x/cache/restic-check-cache-237814189
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 2dd49e74:
decrypting blob <tree/2dd49e74> from pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 contains 2 errors: [blob 2dd49e743c4d0b61ec5cfa2dc99f5446d5e7482635df00484aaa6a193aba98b8: decrypting blob <tree/2dd49e74> from pack fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160 failed: ciphertext verification failed unexpected pack id d22a000a356e727dc3afaeef9408a189de77107618df88519e050153a5a653a8]
[0:00] 100.00% 123 / 123 packs
The repository contains damaged pack files. These damaged files must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.
restic repair packs fd6de2a5f8204c762b7a38c35a81a60785d6a542d3506a63dadad936b0aeb160
restic repair snapshots --forget
Damaged pack files can be caused by backend problems, hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!
Fatal: repository contains errors
@@ -0,0 +1,7 @@
using temporary cache in /data/x/cache/restic-check-cache-2363455331
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 82 / 82 snapshots
no errors were found
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
Fatal: Fatal: create repository at /data/x/repo failed: config file already exists
@@ -0,0 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked exclusively by PID 3694515 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:13 (1.104257918s ago)
storage ID 781ffba8
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked by PID 3693859 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 20:03:12 (1.095585673s ago)
storage ID 03a00a80
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,3 @@
Fatal: repository does not exist: unable to open config file: stat /data/x/nothing-here/config: no such file or directory
Is there a repository at the following location?
/data/x/nothing-here
@@ -0,0 +1,2 @@
rclone: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at rclone:x: error talking HTTP to rclone: exit status 255
@@ -0,0 +1,2 @@
subprocess ssh: u@127.0.0.1: Permission denied (publickey,password).
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 10.255.255.1 port 22: Connection timed out
Fatal: unable to open repository at sftp:u@10.255.255.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"27898187d9337190afb6154237f44817ccf2070d6be912c86af3c1ef55ed8e3d","tree":"b830b8165dc45c4b23086a81004548a5a52a00060028f0d274d8098dcc168b49","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"94fc37b9bfe97271d4a0b0feda4443a5e2934a3557108dd521d0bfb2f49423c8","short_id":"94fc37b9"}]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"total_size":21,"total_file_count":12,"snapshots_count":1}
@@ -0,0 +1 @@
{"total_size":217458,"total_uncompressed_size":345270,"compression_ratio":1.5877548768037966,"compression_progress":100,"compression_space_saving":37.017985924059424,"total_blob_count":863,"snapshots_count":82}
@@ -0,0 +1 @@
Fatal: wrong password or no key found