backup engine internals - streaming, timeouts, typed snapshot layout, volume keys, route guards

This commit is contained in:
jarek committed 2026-08-09 10:31:28 +02:00
1 parent c33c404259
commit 95ee76bf97
32 files changed
+1205 -352

No files matched your search

+22 -4
View File
@@ -3,6 +3,24 @@
* snapshot browser. No docker, no DB, so it is unit-testable directly. * snapshot browser. No docker, no DB, so it is unit-testable directly.
*/ */
/** One restic `ls --json` line. restic emits a `snapshot` header then one entry per node.
* The node discriminator is `message_type: 'node'` in current restic; older restic used
* `struct_type: 'node'` (now deprecated). We accept EITHER so a restic bump that drops the
* old field can't silently empty the browser. */
export interface ResticLsNode {
message_type?: string;
struct_type?: string;
path?: string;
type?: string;
size?: number;
[k: string]: unknown;
}
/** True if a parsed restic ls line is a filesystem node (not the `snapshot` header). */
function isResticNode(e: ResticLsNode): boolean {
return e.message_type === 'node' || e.struct_type === 'node';
}
/** Normalize a path for self-comparison: collapse repeated slashes at the ends, /** Normalize a path for self-comparison: collapse repeated slashes at the ends,
* always leading-slash, no trailing slash (root stays "/"). */ * always leading-slash, no trailing slash (root stays "/"). */
function normalizePath(p: string): string { function normalizePath(p: string): string {
@@ -20,15 +38,15 @@ function normalizePath(p: string): string {
* directory (e.g. an empty `volumes/` inside `/volumes/`, `bt-vol/` inside * directory (e.g. an empty `volumes/` inside `/volumes/`, `bt-vol/` inside
* `/volumes/bt-vol/`). Keep only nodes whose path is NOT the queried path. * `/volumes/bt-vol/`). Keep only nodes whose path is NOT the queried path.
*/ */
export function parseSnapshotLsEntries(stdout: string, queriedPath: string): any[] { export function parseSnapshotLsEntries(stdout: string, queriedPath: string): ResticLsNode[] {
const queried = normalizePath(queriedPath); const queried = normalizePath(queriedPath);
const entries: any[] = []; const entries: ResticLsNode[] = [];
for (const line of stdout.split('\n')) { for (const line of stdout.split('\n')) {
const t = line.trim(); const t = line.trim();
if (!t) continue; if (!t) continue;
let e: any; let e: ResticLsNode;
try { e = JSON.parse(t); } catch { continue; } try { e = JSON.parse(t); } catch { continue; }
if (e?.struct_type !== 'node') continue; if (!isResticNode(e)) continue;
if (normalizePath(e.path ?? '') === queried) continue; // self-node, not a child if (normalizePath(e.path ?? '') === queried) continue; // self-node, not a child
entries.push(e); entries.push(e);
} }
+33 -26
View File
@@ -14,6 +14,22 @@
*/ */
import { isUnbackupableBindSource } from '../../utils/unbackupable-mounts'; import { isUnbackupableBindSource } from '../../utils/unbackupable-mounts';
import { safeKey, volumeDedupKey, volumeStorageKey } from '../../utils/volume-identity';
// Capture and the UI picker share ONE key allocator (utils/volume-identity) so they can't drift.
// Re-exported so server callers (restore-core.ts) import safeKey from here.
export { safeKey };
/** The `/volumes/<key>` mount point a captured/restored volume rides on. ONE definition of the
* mount-point contract so backup capture (ro), restore (rw), and the preview can't drift on it. */
export function volumeInclude(key: string): string {
return `/volumes/${key}`;
}
/** The `<source>:/volumes/<key>:<mode>` helper bind string. Backup capture uses `ro`, restore `rw`. */
export function volumeBind(source: string, key: string, mode: 'ro' | 'rw'): string {
return `${source}:${volumeInclude(key)}:${mode}`;
}
/** A single mount as returned by a container inspect (the fields we use). */ /** A single mount as returned by a container inspect (the fields we use). */
export interface Mount { export interface Mount {
@@ -54,19 +70,6 @@ export interface DiscoveryResult {
const SUPPORTED = new Set(['volume', 'bind']); const SUPPORTED = new Set(['volume', 'bind']);
/** Make a bind Source/Destination into a safe `/volumes/` key. Non-safe chars
* become `_`; a collision gets a short numeric suffix so two mounts never map to
* the same key (which would make restic overwrite one with the other). */
export function safeKey(base: string, taken: Set<string> = new Set()): string {
let key = base.replace(/^\//, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 100) || 'bind';
if (!taken.has(key)) { taken.add(key); return key; }
let i = 2;
while (taken.has(`${key}_${i}`)) i++;
const out = `${key}_${i}`;
taken.add(out);
return out;
}
/** /**
* Turn a list of containers' mounts into the volumes to back up. Named volumes * Turn a list of containers' mounts into the volumes to back up. Named volumes
* key on their volume name (stable, restore knows it); binds key on a * key on their volume name (stable, restore knows it); binds key on a
@@ -77,9 +80,8 @@ export function safeKey(base: string, taken: Set<string> = new Set()): string {
export function discoverVolumesFromMounts(containers: Array<{ name: string; mounts: Mount[] }>): DiscoveryResult { export function discoverVolumesFromMounts(containers: Array<{ name: string; mounts: Mount[] }>): DiscoveryResult {
const volumes: DiscoveredVolume[] = []; const volumes: DiscoveredVolume[] = [];
const skipped: SkippedMount[] = []; const skipped: SkippedMount[] = [];
const seenVolumeNames = new Set<string>(); const seen = new Set<string>(); // a volume shared across containers is captured once
const seenBindPairs = new Set<string>(); const takenKeys = new Set<string>(); // no two captures share a /volumes/<key>
const takenKeys = new Set<string>();
for (const container of containers) { for (const container of containers) {
for (const mount of container.mounts) { for (const mount of container.mounts) {
@@ -93,12 +95,15 @@ export function discoverVolumesFromMounts(containers: Array<{ name: string; moun
} }
if (mount.Type === 'volume' && mount.Name) { if (mount.Type === 'volume' && mount.Name) {
if (seenVolumeNames.has(mount.Name)) continue; const dedup = volumeDedupKey({ type: 'volume', source: mount.Name, destination: mount.Destination ?? '', name: mount.Name });
seenVolumeNames.add(mount.Name); if (seen.has(dedup)) continue;
takenKeys.add(mount.Name); seen.add(dedup);
// Named volumes key on their name; volumeStorageKey routes it through the shared
// allocator so it can't collide with a bind whose slug equals the volume name.
const key = volumeStorageKey({ type: 'volume', source: mount.Name, destination: mount.Destination ?? '', name: mount.Name }, takenKeys);
volumes.push({ volumes.push({
key: mount.Name, key,
bind: `${mount.Name}:/volumes/${mount.Name}:ro`, bind: volumeBind(mount.Name, key, 'ro'),
name: mount.Name, name: mount.Name,
type: 'volume', type: 'volume',
source: mount.Name, source: mount.Name,
@@ -112,13 +117,15 @@ export function discoverVolumesFromMounts(containers: Array<{ name: string; moun
}); });
continue; continue;
} }
const pairKey = `${mount.Source}\n${mount.Destination}`; // Two binds to the same destination from DIFFERENT sources are different volumes
if (seenBindPairs.has(pairKey)) continue; // and both survive (#1373); only an identical (source,destination) is deduped.
seenBindPairs.add(pairKey); const dedup = volumeDedupKey({ type: 'bind', source: mount.Source, destination: mount.Destination });
const key = safeKey(mount.Destination, takenKeys); if (seen.has(dedup)) continue;
seen.add(dedup);
const key = volumeStorageKey({ type: 'bind', source: mount.Source, destination: mount.Destination }, takenKeys);
volumes.push({ volumes.push({
key, key,
bind: `${mount.Source}:/volumes/${key}:ro`, bind: volumeBind(mount.Source, key, 'ro'),
name: mount.Destination, name: mount.Destination,
type: 'bind', type: 'bind',
source: mount.Source, source: mount.Source,
+71
View File
@@ -0,0 +1,71 @@
/**
* backups/error-message.ts — turn restic / Docker error text into a readable
* sentence for the UI. Pure and dependency-free so it is unit-testable directly.
*
* The inputs are messy: ANSI colour codes, a whole JSON error object, an error
* with embedded JSON, and the worst case — a KILLED helper whose "Container
* exited with code N:" detail is followed by several restic `--json` PROGRESS
* lines glued together (no error `message` in any of them). Each case collapses
* to the shortest true, readable string.
*/
/**
* cleanErrorMsg is on the error path — it must NEVER throw and become the crash it
* was meant to describe. Coerce any non-string to its raw string, and wrap the whole
* parse in a try/catch that falls back to the raw input. We never fabricate a message:
* raw text is always better than swallowing the real error.
*/
export function cleanErrorMsg(msg: unknown): string {
const raw = typeof msg === 'string' ? msg : safeString(msg);
try {
return clean(raw);
} catch {
// Any unforeseen failure in parsing → return the raw string untouched.
return raw;
}
}
/** Coerce anything to a string without ever throwing (a hostile toString() can). */
function safeString(v: unknown): string {
try { return String(v); } catch { return ''; }
}
function clean(msg: string): string {
// Strip ANSI escape codes
const clean = msg.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
// Try parsing the whole string as JSON
try { const p = JSON.parse(clean); if (typeof p?.message === 'string' && p.message) return p.message; } catch { /* not pure JSON */ }
// Try extracting embedded JSON — replace real newlines so JSON.parse works
const jsonStart = clean.indexOf('{');
const jsonEnd = clean.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
try {
const jsonStr = clean.slice(jsonStart, jsonEnd + 1).replace(/\n/g, '\\n');
const parsed = JSON.parse(jsonStr);
// Only a STRING message is usable — a number/object would cast to "123"
// or "[object Object]", which is worse than the raw text.
if (typeof parsed?.message === 'string' && parsed.message) {
const prefix = clean.slice(0, jsonStart).trim();
const message = parsed.message.replace(/\\n/g, ' ').trim();
return prefix ? `${prefix} ${message}` : message;
}
if (parsed.message_type) {
// A killed helper appends restic's last --json PROGRESS line
// (message_type, no `message`) to the exit-code detail — no error
// text; drop it, keep just the readable prefix.
const p2 = clean.slice(0, jsonStart).trim().replace(/:\s*$/, '');
if (p2) return p2;
}
} catch {
// The whole slice didn't parse as one JSON - which is what a KILLED helper
// produces: "Container exited with code N: {progress} {progress} ...",
// several restic --json PROGRESS lines concatenated after the exit detail.
// None carry an error message; keep just the readable prefix before them.
if (/^\{.*"message_type"/.test(clean.slice(jsonStart, jsonStart + 200))) {
const p3 = clean.slice(0, jsonStart).trim().replace(/:\s*$/, '');
if (p3) return p3;
}
}
}
return clean;
}
+152 -112
View File
@@ -7,7 +7,7 @@
import { isValidCron } from '../scheduler/cron-utils'; import { isValidCron } from '../scheduler/cron-utils';
import { privateIpReason, dangerousHostReason, isSafeWebhookUrl, isSafeNotificationUrl } from '../url-safety'; import { privateIpReason, dangerousHostReason, isSafeWebhookUrl, isSafeNotificationUrl } from '../url-safety';
import { BackupError } from './models'; import { BackupError, isLocalRepo } from './models';
export { privateIpReason }; export { privateIpReason };
// Restic exits 10 when the repo isn't initialized. // Restic exits 10 when the repo isn't initialized.
@@ -30,11 +30,98 @@ export function parseOptionsJson(options: string | null | undefined): Record<str
} }
} }
/** The job.options shape a backup run consumes (subset of what's persisted on a config). */
export interface JobOptions {
excludePatterns?: string[];
excludeCaches?: boolean;
compression?: string;
limitUpload?: number;
limitDownload?: number;
webhookSuccess?: string;
webhookFailure?: string;
excludedStackFiles?: string[];
}
/**
* Map a config's stored options (from parseOptionsJson) into the job.options a backup run uses.
* Pure so a SAVED backup (scheduled or "run now") gets EXACTLY the same options as an in-request
* run. Every persisted field must be forwarded here, or it silently vanishes on the config-driven path.
*/
export function buildJobOptions(opts: Record<string, any>): JobOptions {
return {
excludePatterns: typeof opts.excludePatterns === 'string'
? opts.excludePatterns.split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined,
excludeCaches: opts.excludeCaches,
compression: opts.compression,
limitUpload: opts.limitUpload,
limitDownload: opts.limitDownload,
webhookSuccess: typeof opts.webhookSuccess === 'string' ? opts.webhookSuccess : undefined,
webhookFailure: typeof opts.webhookFailure === 'string' ? opts.webhookFailure : undefined,
excludedStackFiles: Array.isArray(opts.excludedStackFiles) ? opts.excludedStackFiles : undefined,
};
}
/** A destination's extra restic flags, split by scope. `backup` applies to backup + repo
* ops; `restore` applies only to `restic restore`. */
export interface BackupFlags {
backup: string;
restore: string;
}
/**
* Parse the stored `flags` column into typed {backup, restore}. The column is a JSON
* object `{"backup":"...","restore":"..."}`. A LEGACY value (a bare flag string like
* `--limit-upload 5120`, or malformed JSON) is treated as BACKUP flags with an empty
* restore string. SINGLE place that string-vs-JSON is decided, so the ambiguity can't
* leak elsewhere.
*/
export function parseBackupFlags(flags: string | null | undefined): BackupFlags {
if (!flags || !flags.trim()) return { backup: '', restore: '' };
const trimmed = flags.trim();
// Only a JSON OBJECT counts as the new shape; anything else is a legacy flag string.
if (trimmed.startsWith('{')) {
try {
const o = JSON.parse(trimmed);
if (o && typeof o === 'object' && !Array.isArray(o)) {
return {
backup: typeof o.backup === 'string' ? o.backup : '',
restore: typeof o.restore === 'string' ? o.restore : '',
};
}
} catch { /* fall through to legacy */ }
}
return { backup: trimmed, restore: '' }; // legacy bare string = backup flags
}
/** Serialize typed {backup, restore} back into the JSON stored in the `flags` column.
* Returns null when both are empty (keeps the column NULL rather than storing `{}`). */
export function serializeBackupFlags(f: BackupFlags): string | null {
const backup = (f.backup ?? '').trim();
const restore = (f.restore ?? '').trim();
if (!backup && !restore) return null;
return JSON.stringify({ backup, restore });
}
/**
* Parse a stored `selected_volumes` JSON blob into a `string[]` or null. Guards the SHAPE,
* not just the JSON: a corrupt/legacy row holding a string (or other non-array) must NOT
* flow into `new Set(...)` as an iterable-of-chars, which would match no volume key and
* silently filter EVERY volume out (a metadata-only snapshot reported as success). Any
* non-array parses to null = "all volumes" (the safe default).
*/
export function parseSelectedVolumes(raw: string | null | undefined): string[] | null {
if (!raw) return null;
let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { return null; }
if (!Array.isArray(parsed)) return null;
return parsed.filter((x): x is string => typeof x === 'string');
}
/** /**
* Parse a stored destination `policies` JSON blob. Bad/missing JSON → empty * Parse a stored destination `policies` JSON blob. Bad/missing JSON → empty
* object, logging ONCE so a corrupt policies field is diagnosable rather than * object, logging ONCE so a corrupt policies field is diagnosable rather than
* silently disabling autoUnlock/prune/check/verify. This is the single source of * silently disabling autoUnlock/prune/check/verify.
* truth for the previously-duplicated inline IIFEs.
*/ */
export function parsePoliciesJson(policies: string | null | undefined): Record<string, any> { export function parsePoliciesJson(policies: string | null | undefined): Record<string, any> {
if (!policies) return {}; if (!policies) return {};
@@ -87,7 +174,7 @@ export const ALLOWED_REPO_SCHEMES = ['rest:', 's3:', 'b2:', 'azure:', 'gs:'] as
export function isAllowedRepository(repository: string | null | undefined): boolean { export function isAllowedRepository(repository: string | null | undefined): boolean {
if (!repository || typeof repository !== 'string') return false; if (!repository || typeof repository !== 'string') return false;
const repo = repository.trim(); const repo = repository.trim();
if (repo.startsWith('/')) return true; // local absolute path if (isLocalRepo(repo)) return true;
return ALLOWED_REPO_SCHEMES.some((s) => repo.startsWith(s)); return ALLOWED_REPO_SCHEMES.some((s) => repo.startsWith(s));
} }
@@ -114,8 +201,7 @@ export function sanitizeWebhookUrlForLog(raw: string): string {
/** /**
* Fire a per-config backup webhook with a JSON payload. Best-effort and * Fire a per-config backup webhook with a JSON payload. Best-effort and
* fire-and-forget: a webhook never changes the backup outcome. Ported from the * fire-and-forget: a webhook never changes the backup outcome. Three hardening layers:
* legacy engine, keeping its three hardening layers:
* - SSRF literal-host guard (isSafeWebhookUrl): block loopback/private/metadata. * - SSRF literal-host guard (isSafeWebhookUrl): block loopback/private/metadata.
* - DNS-rebinding guard: resolve the hostname and re-check EVERY resolved IP * - DNS-rebinding guard: resolve the hostname and re-check EVERY resolved IP
* against the private/metadata ranges before connecting, so a public-looking * against the private/metadata ranges before connecting, so a public-looking
@@ -234,75 +320,14 @@ export function isValidSnapshotId(id: string): boolean {
* the human message, with the surrounding prefix preserved. Also strips ANSI * the human message, with the surrounding prefix preserved. Also strips ANSI
* colour escapes that restic sometimes emits when stderr is a TTY (the helper * colour escapes that restic sometimes emits when stderr is a TTY (the helper
* container's stderr looks like a TTY to restic). */ * container's stderr looks like a TTY to restic). */
export function cleanErrorMsg(msg: string): string { // cleanErrorMsg lives in error-message.ts; re-exported so importers of helpers.ts keep working.
// Strip ANSI escape codes export { cleanErrorMsg } from './error-message';
const clean = msg.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
// Try parsing the whole string as JSON
try { const p = JSON.parse(clean); if (p.message) return p.message; } catch { /* not pure JSON */ }
// Try extracting embedded JSON — replace real newlines so JSON.parse works
const jsonStart = clean.indexOf('{');
const jsonEnd = clean.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
try {
const jsonStr = clean.slice(jsonStart, jsonEnd + 1).replace(/\n/g, '\\n');
const parsed = JSON.parse(jsonStr);
if (parsed.message) {
const prefix = clean.slice(0, jsonStart).trim();
const message = parsed.message.replace(/\\n/g, ' ').trim();
return prefix ? `${prefix} ${message}` : message;
}
if (parsed.message_type) {
// A killed helper appends restic's last --json PROGRESS line
// (message_type, no `message`) to the exit-code detail — no error
// text; drop it, keep just the readable prefix.
const p2 = clean.slice(0, jsonStart).trim().replace(/:\s*$/, '');
if (p2) return p2;
}
} catch { /* not embedded JSON */ }
}
return clean;
}
/** /**
* Inspect `restic forget --dry-run --json` output and decide whether running * The delete-everything guard for `restic forget --dry-run --json`. Re-exported from retention.ts
* the real `forget --prune` would delete every snapshot in the targeted group. * (`checkWouldWipe`) so this data-loss guard lives in exactly one place - a second copy could drift.
*
* Restic's JSON shape for `forget` is an array of "forget groups", each with
* `keep`, `remove`, and `reasons`. Snapshots are objects with an `id` field;
* we count list lengths rather than poking at fields. When the policy keeps
* zero and removes everything, that's the dangerous case we want to block.
*
* Empty/unparseable stdout → `{ wouldWipe: false, ... zeros }`. Restic emits
* an empty array when the repo holds no snapshots, which is also "not a
* wipe-out worth blocking" because there's nothing to lose.
*
* Pure function, no I/O — the caller runs it after running restic; tests can
* pass canned stdout.
*/ */
export function wouldDeleteAllSnapshots(stdout: string): { export { checkWouldWipe as wouldDeleteAllSnapshots } from './retention';
wouldWipe: boolean;
keep: number;
remove: number;
total: number;
} {
let keep = 0;
let remove = 0;
// Restic forget --json emits a single JSON document (array of groups).
// Parse the whole stdout once; ignore trailing whitespace.
const trimmed = stdout.trim();
if (!trimmed) return { wouldWipe: false, keep: 0, remove: 0, total: 0 };
let parsed: unknown;
try { parsed = JSON.parse(trimmed); } catch { return { wouldWipe: false, keep: 0, remove: 0, total: 0 }; }
if (!Array.isArray(parsed)) return { wouldWipe: false, keep: 0, remove: 0, total: 0 };
for (const group of parsed) {
if (!group || typeof group !== 'object') continue;
const g = group as { keep?: unknown[]; remove?: unknown[] };
if (Array.isArray(g.keep)) keep += g.keep.length;
if (Array.isArray(g.remove)) remove += g.remove.length;
}
const total = keep + remove;
return { wouldWipe: total > 0 && keep === 0 && remove > 0, keep, remove, total };
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// restic subprocess hardening // restic subprocess hardening
@@ -356,10 +381,8 @@ export function filterCloudEnvVars(envVars: Record<string, string>): Record<stri
return out; return out;
} }
// SSRF URL-safety primitives now live in the neutral $lib/server/url-safety // SSRF URL-safety primitives live in the neutral $lib/server/url-safety module
// module so subsystems (backups, notifications) import DOWN into a shared home // (shared by backups + notifications). Re-exported here so backup-side importers keep resolving.
// rather than the notification router importing UP from the backup engine.
// Re-exported here so existing backup-side importers keep resolving unchanged.
export { isSafeWebhookUrl }; export { isSafeWebhookUrl };
/** /**
@@ -384,56 +407,67 @@ export function buildResticEnv(
return env; return env;
} }
// restic global/backup/restore flags that are safe for an operator to append // Restic flags a destination operator may append. Backup/global flags apply to backup +
// per destination. Anything that can read/write arbitrary files or run code // repo ops; restore flags apply ONLY to `restic restore` (which has its own flags like
// (--option, --password-command, --cacert to an arbitrary path, etc.) is // --exclude-xattr, #1349). The two are stored SEPARATELY (see BackupFlags), so a
// rejected. Flags may be `--flag` or `--flag=value`. // restore-only flag can never leak into `restic backup`. Anything that can read/write
const RESTIC_FLAG_ALLOW = new Set([ // arbitrary files or run code (--option, --password-command, --cacert to an arbitrary
// path) is rejected. Flags may be `--flag` or `--flag=value`.
const RESTIC_BACKUP_FLAG_ALLOW = new Set([
'--limit-upload', '--limit-download', '--no-cache', '--cleanup-cache', '--limit-upload', '--limit-download', '--no-cache', '--cleanup-cache',
'--pack-size', '--compression', '--no-lock', '--json', '--quiet', '--verbose', '--pack-size', '--compression', '--no-lock', '--json', '--quiet', '--verbose',
'--tls-client-cert', '--retry-lock', '--insecure-tls' '--tls-client-cert', '--retry-lock', '--insecure-tls'
]); ]);
// Restore adds --exclude-xattr (restore-only in restic; #1349 SELinux xattr the
// unprivileged helper can't remove on a cross-distro restore).
const RESTIC_RESTORE_FLAG_ALLOW = new Set([...RESTIC_BACKUP_FLAG_ALLOW, '--exclude-xattr']);
// Allowlisted flags that take a VALUE. Written joined (`--retry-lock=10m`) or // Allowlisted flags that take a VALUE. Written joined (`--retry-lock=10m`) or
// space-separated (`--retry-lock 10m`); in the space-separated form the FOLLOWING // space-separated (`--retry-lock 10m`); in the space-separated form the FOLLOWING
// token is that value, not a flag, so it legitimately doesn't start with `--`. // token is that value, not a flag, so it legitimately doesn't start with `--`.
const RESTIC_FLAG_TAKES_VALUE = new Set([ const RESTIC_FLAG_TAKES_VALUE = new Set([
'--limit-upload', '--limit-download', '--pack-size', '--compression', '--limit-upload', '--limit-download', '--pack-size', '--compression',
'--tls-client-cert', '--retry-lock' '--tls-client-cert', '--retry-lock', '--exclude-xattr'
]); ]);
/** /** Core allowlist validator, parameterized by which allowlist applies. */
* Split and validate a destination's extra restic CLI flags against an function sanitizeAgainst(flags: string | null | undefined, allow: Set<string>): string[] {
* allowlist. Returns the accepted flag tokens; throws on any flag not in the
* allowlist so we never forward attacker-controlled options like `--option`,
* `--password-command`, or a bare non-flag argument. A value that follows a
* space-separated value-taking flag (e.g. the `10m` in `--retry-lock 10m`) is
* accepted as that flag's argument rather than treated as a flag itself.
*/
export function sanitizeResticFlags(flags: string | null | undefined): string[] {
if (!flags || !flags.trim()) return []; if (!flags || !flags.trim()) return [];
const tokens = flags.trim().split(/\s+/); const tokens = flags.trim().split(/\s+/);
const out: string[] = []; const out: string[] = [];
let expectValue = false; let expectValue = false;
for (const tok of tokens) { for (const tok of tokens) {
if (expectValue) { if (expectValue) {
// This token is the value of the previous value-taking flag. out.push(tok); // value of the previous space-separated value-taking flag
out.push(tok);
expectValue = false; expectValue = false;
continue; continue;
} }
const name = tok.startsWith('--') ? tok.split('=')[0] : tok; const name = tok.startsWith('--') ? tok.split('=')[0] : tok;
if (!name.startsWith('--') || !RESTIC_FLAG_ALLOW.has(name)) { if (!name.startsWith('--') || !allow.has(name)) {
throw new Error(`Disallowed restic flag: ${tok}`); throw new Error(`Disallowed restic flag: ${tok}`);
} }
out.push(tok); out.push(tok);
// A space-separated value-taking flag (no `=value`) consumes the next token.
if (RESTIC_FLAG_TAKES_VALUE.has(name) && !tok.includes('=')) expectValue = true; if (RESTIC_FLAG_TAKES_VALUE.has(name) && !tok.includes('=')) expectValue = true;
} }
if (expectValue) throw new Error('Restic flag is missing its value'); if (expectValue) throw new Error('Restic flag is missing its value');
return out; return out;
} }
/**
* Split and validate a destination's BACKUP/global extra restic flags against the backup
* allowlist. Throws on any flag not allowed (never forwards --option/--password-command/etc).
*/
export function sanitizeResticFlags(flags: string | null | undefined): string[] {
return sanitizeAgainst(flags, RESTIC_BACKUP_FLAG_ALLOW);
}
/** Split and validate a destination's RESTORE extra restic flags (backup allowlist plus
* restore-only flags like --exclude-xattr). */
export function sanitizeRestoreFlags(flags: string | null | undefined): string[] {
return sanitizeAgainst(flags, RESTIC_RESTORE_FLAG_ALLOW);
}
/** /**
* Conservative default retention for SCHEDULED configs created without a policy — * Conservative default retention for SCHEDULED configs created without a policy —
* otherwise their snapshots grow forever, since pruning is gated on a non-empty * otherwise their snapshots grow forever, since pruning is gated on a non-empty
@@ -454,21 +488,11 @@ export function retentionToStore(retention: any, schedule: unknown): string | nu
} }
/** /**
* Decide the `enabled` flag when a backup config is updated, auto-enabling a config * Decide the `enabled` flag when a backup config is updated. A "run once" backup persists a
* that gains a schedule. * MANUAL, paused config (schedule=null, enabled=false); adding a cron schedule later should make
* * it actually run, so a manual -> scheduled transition auto-enables it. A config that was ALREADY
* The motivating flow: a "run once" backup persists a MANUAL, paused config * scheduled is left alone, so a deliberately-paused scheduled backup stays paused across edits.
* (schedule=null, enabled=false) — correct on its own. But when the user later edits * Applies to container and stack backups alike (both go through PUT).
* that config to ADD a cron schedule, they expect it to actually run; leaving it
* paused (because the run-once config was created disabled and the UI's Enabled
* toggle still reflected that) is a surprise that forces a manual un-pause on the
* Schedules page. So: a transition from manual (no schedule) to scheduled (a real
* cron) auto-enables the config.
*
* This deliberately does NOT touch a config that was ALREADY scheduled — a user who
* paused a scheduled backup on purpose keeps it paused across edits. Same rationale
* as retentionToStore: adding a schedule should leave the config in a sane, working
* state. Applies identically to container and stack backups (both go through PUT).
*/ */
export function resolveEnabledOnScheduleChange(input: { export function resolveEnabledOnScheduleChange(input: {
requestedEnabled: unknown; requestedEnabled: unknown;
@@ -512,8 +536,24 @@ export function validateRepositoryForSave(repository: string): string | null {
* throws on a disallowed flag; surface that as an error string, or null if ok. */ * throws on a disallowed flag; surface that as an error string, or null if ok. */
export function validateFlags(flags: unknown): string | null { export function validateFlags(flags: unknown): string | null {
if (flags === undefined || flags === null) return null; if (flags === undefined || flags === null) return null;
try { sanitizeResticFlags(flags as string); return null; } // The route may send either the split shape ({ backup, restore }) or a legacy bare
catch (e) { return e instanceof Error ? e.message : 'Invalid restic flags'; } // string. Validate each scope against its own allowlist (restore permits --exclude-xattr).
const { backup, restore } = parseBackupFlags(typeof flags === 'string' ? flags : serializeBackupFlags(flags as BackupFlags) ?? '');
try {
sanitizeResticFlags(backup);
sanitizeRestoreFlags(restore);
return null;
} catch (e) { return e instanceof Error ? e.message : 'Invalid restic flags'; }
}
/** Validate a raw {backup, restore} pair from the API and return the serialized flags
* column value (JSON, or null when both empty), or throw with the allowlist error. */
export function validateAndSerializeFlags(backup: unknown, restore: unknown): string | null {
const b = typeof backup === 'string' ? backup : '';
const r = typeof restore === 'string' ? restore : '';
sanitizeResticFlags(b); // throws on a disallowed backup flag
sanitizeRestoreFlags(r); // throws on a disallowed restore flag
return serializeBackupFlags({ backup: b, restore: r });
} }
/** /**
+10 -1
View File
@@ -31,9 +31,18 @@
* different keys, fail to collide, and run concurrently — a torn snapshot * different keys, fail to collide, and run concurrently — a torn snapshot
* reported as success. * reported as success.
*/ */
export function liveTargetKey(envId: number | null | undefined, dataPaths: string[]): string { export function liveTargetKey(envId: number | null | undefined, dataPaths: string[], fallbackIdentity?: string): string {
const env = envId ?? 'local'; const env = envId ?? 'local';
const sources = dataPaths.map(bindSource).filter(Boolean).sort(); const sources = dataPaths.map(bindSource).filter(Boolean).sort();
// Config-only targets (no named volumes, no binds) have NO data sources, so keying on
// sources alone makes every such target on the same env collide (`${env}::`), and the
// REJECT lock silently skips all but one - two disjoint config-only backups in one cron
// window lose one every run. Fall back to a per-target identity ONLY when there are no
// sources, so disjoint config-only targets don't alias. Targets that DO share data still
// key on the data identity (a backup:ro and restore:rw of the same volume must collide).
if (sources.length === 0 && fallbackIdentity) {
return `${env}::config:${fallbackIdentity}`;
}
return `${env}::${sources.join('|')}`; return `${env}::${sources.join('|')}`;
} }
+14 -6
View File
@@ -29,8 +29,7 @@ export type OperationStatus =
| 'success' | 'success'
| 'warning' | 'warning'
| 'error' | 'error'
| 'cancelled' | 'cancelled';
| 'stale';
/** /**
* Closed error taxonomy. The UI shows a human message; automation branches on * Closed error taxonomy. The UI shows a human message; automation branches on
@@ -226,6 +225,7 @@ export function parseSnapshots(stdout: string): Snapshot[] {
return out; return out;
} }
// ============================================================================= // =============================================================================
// Snapshot tags — STABLE IDs, never normalized names // Snapshot tags — STABLE IDs, never normalized names
// ============================================================================= // =============================================================================
@@ -235,10 +235,9 @@ export function parseSnapshots(stdout: string): Snapshot[] {
* ownership, env-access) use ONLY the stable-id tags; the name/type tags are * ownership, env-access) use ONLY the stable-id tags; the name/type tags are
* decoration for humans browsing with the restic CLI. * decoration for humans browsing with the restic CLI.
* *
* Why stable ids: the old code tagged `dockhand:config=<name>-<type>-<env>` * Why stable ids, not names: a normalized env name collides when two env names normalize to the
* built from a NORMALIZED env name, which collides when two env names normalize * same string, which would cross-prune retention and resolve env-access to the wrong environment.
* to the same string — causing retention to cross-prune and env-access to * Ids never collide.
* resolve to the wrong environment. Ids never collide.
*/ */
export type BackupTargetType = 'container' | 'stack'; export type BackupTargetType = 'container' | 'stack';
@@ -308,3 +307,12 @@ export const TIMEOUTS = {
} as const; } as const;
export type TimeoutTier = keyof typeof TIMEOUTS; export type TimeoutTier = keyof typeof TIMEOUTS;
/** The SINGLE definition of "this restic repository is a local filesystem path" (vs a
* rest:/s3:/... scheme URL). Local = an ABSOLUTE path, the only form the save gate
* (isAllowedRepository) accepts. Every site that binds/guards/chowns the local repo MUST
* use this so they can't diverge - a `./`-vs-`/` mismatch would guard a path the helper
* never mounts, hard-failing a valid repo with a misleading "repository not found". */
export function isLocalRepo(repository: string): boolean {
return repository.startsWith('/');
}
+5 -17
View File
@@ -7,18 +7,17 @@
* A record opens as `running`, streams throttled progress + log lines, and * A record opens as `running`, streams throttled progress + log lines, and
* closes exactly once with a terminal status DERIVED IN ONE PLACE (see * closes exactly once with a terminal status DERIVED IN ONE PLACE (see
* ./operations-core). The startup scrub rewrites any row left `running` by a * ./operations-core). The startup scrub rewrites any row left `running` by a
* dead process to `stale`, so an interrupted operation is always resolvable * dead process to `failed`, so an interrupted operation is always resolvable
* without a transaction — "running at boot" is unambiguously not-completed. * without a transaction — "running at boot" is unambiguously not-completed.
* *
* The pure decision logic (terminal-status derivation, progress throttle, scrub * The pure decision logic (terminal-status derivation, progress throttle) lives
* rule) lives in ./operations-core and is unit-tested there; this module is the * in ./operations-core and is unit-tested there; this module is the thin
* thin DB-bound wrapper. * DB-bound wrapper.
*/ */
import { import {
createScheduleExecution, createScheduleExecution,
updateScheduleExecution, updateScheduleExecution,
appendScheduleExecutionLog, appendScheduleExecutionLog,
failStaleRunningExecutions,
} from '../db'; } from '../db';
import type { OperationKind } from './models'; import type { OperationKind } from './models';
import { import {
@@ -91,7 +90,7 @@ export class Operation {
* metadata/verifying/pruning, the volume list, errors/warnings) and is ALWAYS * metadata/verifying/pruning, the volume list, errors/warnings) and is ALWAYS
* persisted — otherwise those lines drop out of the execution log * persisted — otherwise those lines drop out of the execution log
* non-deterministically when they land in a throttle window occupied by restic * non-deterministically when they land in a throttle window occupied by restic
* spam. (That was a real bug: stage markers vanished from the log under load.) * spam.
*/ */
progress(status: string, message: string, detail?: unknown): void { progress(status: string, message: string, detail?: unknown): void {
if (this.onProgressCb) this.onProgressCb(status, message, detail); if (this.onProgressCb) this.onProgressCb(status, message, detail);
@@ -138,14 +137,3 @@ export class Operation {
export function openOperation(opts: OpenOptions, onProgress?: (status: string, message: string, detail?: unknown) => void): Promise<Operation> { export function openOperation(opts: OpenOptions, onProgress?: (status: string, message: string, detail?: unknown) => void): Promise<Operation> {
return Operation.open(opts, onProgress); return Operation.open(opts, onProgress);
} }
/**
* Startup scrub: mark any operation left `running`/`queued` by a dead process as
* a terminal failure so it can't linger forever and inflate "in progress"
* metrics. Delegates to the shared DB sweep (which sets them failed) — a
* follow-up may split `stale` from `failed` at the DB layer, but the invariant
* (no row stays `running` across a restart) holds today.
*/
export async function scrubInterruptedOperations(): Promise<number> {
return failStaleRunningExecutions();
}
@@ -0,0 +1,58 @@
/**
* Pure transport predicates for the streaming put-archive path. Import-light (no docker.ts /
* db deps) so it's unit-testable — this is the edge-cap SAFEGUARD the whole streaming design
* leans on: if this wrongly reported an edge env as streamable, the uncapped stream path would
* run and the buffered+capped fallback would never engage (OOM risk).
*/
import { PassThrough, type Readable } from 'node:stream';
/** Minimal shape we branch on (a subset of DockerClientConfig). */
export interface TransportConfig {
connectionType?: string | null;
}
// A stalled tar upload has NO absolute timeout (a legit multi-GB tar can take a long time), so its
// only backstop is the helper's 6h ceiling - a half-dead agent that accepts the connection then
// stops reading would hang for hours. An IDLE (progress-based) watchdog distinguishes "slow but
// alive" from "dead": it fails only after N minutes of ZERO bytes flowing, and resets on every chunk.
export const TAR_IDLE_TIMEOUT_MS = Number(process.env.BACKUP_TAR_IDLE_MS) || 5 * 60_000;
/** Wrap a tar stream so it aborts (destroys) after TAR_IDLE_TIMEOUT_MS of zero throughput. The
* timer resets on every chunk that flows through (i.e. every chunk the receiver actually read via
* backpressure), so a stalled receiver trips it while a slow-but-progressing upload does not.
* Import-light (node:stream only) so it's unit-testable without docker.ts. */
export function withIdleWatchdog(tar: Readable, label: string, idleMs = TAR_IDLE_TIMEOUT_MS): Readable {
const out = new PassThrough();
let timer: ReturnType<typeof setTimeout> | null = null;
const arm = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const err = new Error(`${label}: no data flowed for ${Math.round(idleMs / 1000)}s - the target is not reading (stalled/dead); aborting upload`);
tar.destroy(err);
out.destroy(err);
}, idleMs);
};
const disarm = () => { if (timer) { clearTimeout(timer); timer = null; } };
// A chunk crossing the PassThrough means the receiver drained the previous one (backpressure) -
// real forward progress. Reset the idle timer on each.
out.on('data', arm);
out.on('end', disarm);
out.on('close', disarm);
tar.on('error', (e) => out.destroy(e));
arm();
return tar.pipe(out);
}
/** True when this env's transport can stream a request body with O(1) RAM. hawser-edge CANNOT
* (its WebSocket protocol base64s the whole tar into one frame); everything else (socket,
* direct, hawser-standard, and the local/undefined case) goes through node http/https core,
* which streams. Keep this the SINGLE source of the stream-vs-buffer decision. */
export function transportCanStream(config: TransportConfig): boolean {
return config.connectionType !== 'hawser-edge';
}
/** Strip shell/URL-dangerous chars from an archive path before it goes into the PUT query. */
export function sanitizeArchivePath(path: string): string {
return path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Idle-timeout policy for Docker API requests over http/https. Import-light + pure so it's
* unit-testable. Returns the socket idle timeout in ms, or null for NO timeout.
*
* A request whose BODY is a stream (a multi-GB tar PUT to /archive) must NOT get the 30s
* small-request idle timeout: it can legitimately stall >30s (slow disk read, mTLS
* backpressure, a GC pause) without being dead, and a 30s timer would destroy the transfer
* mid-upload leaving the helper a truncated tar. This is independent of how the RESPONSE is
* read (the response is tiny): body streaming and response streaming are tracked separately.
*/
export interface RequestTimeoutInput {
/** The request path (used to bump compose / prune to longer timeouts). */
path: string;
/** True when the request body is a stream (large upload) - suppresses the idle timeout. */
streamingBody: boolean;
/** True when the RESPONSE is consumed as a stream (logs/events follow) - also no timeout. */
streamingResponse: boolean;
/** COMPOSE_TIMEOUT env, seconds; defaults to 900. */
composeTimeoutSecs?: number;
}
export function computeRequestTimeoutMs(input: RequestTimeoutInput): number | null {
// No idle timeout when either end streams: a large body upload or a long-lived response.
if (input.streamingBody || input.streamingResponse) return null;
if (input.path === '/_hawser/compose') return (input.composeTimeoutSecs ?? 900) * 1000;
if (input.path.endsWith('/prune')) return 300000;
return 30000;
}
+19 -14
View File
@@ -16,17 +16,16 @@
* true source for a bind key, we FAIL LOUD rather than fall back to the slug. * true source for a bind key, we FAIL LOUD rather than fall back to the slug.
*/ */
import { BackupError } from './models'; import { BackupError } from './models';
import { safeKey } from './discovery-core'; import { safeKey, volumeInclude, volumeBind } from './discovery-core';
import type { SnapshotLayout, SnapshotVolume } from './snapshot-layout';
/** One volume entry as stored in a snapshot's metadata.json (post-enrichment). */ export { volumeInclude, volumeBind };
export interface StoredVolume {
key: string;
name: string;
source: string;
type: 'volume' | 'bind';
}
/** A docker mount as recorded in metadata.container (docker inspect Mounts[]). */ /** One volume entry as stored in a snapshot (the typed contract, not a parallel copy - so
* a SnapshotVolume field rename is a compile error here too, not a silent undefined). */
export type StoredVolume = SnapshotVolume;
/** A docker mount as recorded in a snapshot's container inspect (Mounts[]). */
interface StoredMount { interface StoredMount {
Type?: string; Type?: string;
Name?: string; Name?: string;
@@ -34,10 +33,14 @@ interface StoredMount {
Destination?: string; Destination?: string;
} }
/** The snapshot metadata shape we read (only the fields this mapping needs). */ /** The snapshot metadata this mapping reads. `volumes` is intentionally `unknown`: this is
* the boundary that reads BOTH the typed SnapshotVolume[] (current) AND legacy string[]
* name/destination entries (pre-enrichment snapshots), narrowing each defensively. A
* SnapshotLayout is assignable here (its volumes: SnapshotVolume[] widens to unknown).
* `container` stays unknown (opaque inspect JSON); mountSource narrows it. */
export interface SnapshotMetadata { export interface SnapshotMetadata {
volumes?: unknown; volumes?: unknown;
container?: { Mounts?: StoredMount[] } | null; container?: unknown;
} }
/** A `<source>:/volumes/<key>:rw` bind + its `/volumes/<key>` include. `type` and /** A `<source>:/volumes/<key>:rw` bind + its `/volumes/<key>` include. `type` and
@@ -52,7 +55,7 @@ export interface VolumeBind {
/** Build the rw bind for a resolved source + key. */ /** Build the rw bind for a resolved source + key. */
function bindFor(source: string, key: string, type: 'volume' | 'bind'): VolumeBind { function bindFor(source: string, key: string, type: 'volume' | 'bind'): VolumeBind {
return { bind: `${source}:/volumes/${key}:rw`, include: `/volumes/${key}`, source, type }; return { bind: volumeBind(source, key, 'rw'), include: volumeInclude(key), source, type };
} }
/** /**
@@ -111,7 +114,8 @@ function enrichedVolume(metadata: SnapshotMetadata | null, key: string): StoredV
/** Recover a source from the stored docker inspect Mounts for a key. */ /** Recover a source from the stored docker inspect Mounts for a key. */
function mountSource(metadata: SnapshotMetadata | null, key: string): { source: string; type: 'bind' | 'volume' } | null { function mountSource(metadata: SnapshotMetadata | null, key: string): { source: string; type: 'bind' | 'volume' } | null {
const mounts = metadata?.container?.Mounts; const c = metadata?.container as { Mounts?: StoredMount[] } | null | undefined;
const mounts = c?.Mounts;
if (!Array.isArray(mounts)) return null; if (!Array.isArray(mounts)) return null;
for (const m of mounts) { for (const m of mounts) {
if (m?.Type === 'volume' && m.Name && m.Name === key) return { source: m.Name, type: 'volume' }; if (m?.Type === 'volume' && m.Name && m.Name === key) return { source: m.Name, type: 'volume' };
@@ -143,7 +147,8 @@ function keyIsKnownBind(metadata: SnapshotMetadata | null, key: string): boolean
if (v && typeof v === 'object' && sv.key === key && sv.type === 'bind') return true; if (v && typeof v === 'object' && sv.key === key && sv.type === 'bind') return true;
} }
} }
const mounts = metadata?.container?.Mounts; const c = metadata?.container as { Mounts?: StoredMount[] } | null | undefined;
const mounts = c?.Mounts;
if (Array.isArray(mounts)) { if (Array.isArray(mounts)) {
for (const m of mounts) { for (const m of mounts) {
if (m?.Type === 'bind' && m.Destination && safeKey(m.Destination) === key) return true; if (m?.Type === 'bind' && m.Destination && safeKey(m.Destination) === key) return true;
+19 -14
View File
@@ -1,5 +1,5 @@
/** /**
* backups/retention.ts — pure retention planning. * backups/retention.ts - pure retention planning.
* *
* Retention decides which snapshots restic keeps and which it forgets. Two * Retention decides which snapshots restic keeps and which it forgets. Two
* safety properties matter and both live here as pure functions: * safety properties matter and both live here as pure functions:
@@ -14,7 +14,7 @@
* that keeps nothing is always a mistake, not an intent. * that keeps nothing is always a mistake, not an intent.
* *
* The policy is a discriminated union so contradictory settings are * The policy is a discriminated union so contradictory settings are
* unrepresentable — you cannot ask for "keep last 5" and "keep 6 monthly" at * unrepresentable - you cannot ask for "keep last 5" and "keep 6 monthly" at
* once; you pick one mode. * once; you pick one mode.
*/ */
@@ -38,10 +38,10 @@ function positiveInt(v: unknown): number | undefined {
/** /**
* Parse a stored retention JSON blob into a RetentionPolicy. The stored shape is * Parse a stored retention JSON blob into a RetentionPolicy. The stored shape is
* `{ keepLast?, keepDaily?, keepWeekly?, keepMonthly?, keepYearly? }` (legacy * `{ keepLast?, keepDaily?, keepWeekly?, keepMonthly?, keepYearly? }`. Absent /
* flat form). Absent / all-zero / unparseable → `none` (the safe default), so a * all-zero / unparseable resolves to `none` (the safe default), so a broken config
* broken config never prunes. `keepLast` takes precedence over time buckets if * never prunes. `keepLast` takes precedence over time buckets if both are somehow
* both are somehow present (the UI only sets one). * present (the UI only sets one).
*/ */
export function parseRetention(stored: string | null | undefined | Record<string, unknown>): RetentionPolicy { export function parseRetention(stored: string | null | undefined | Record<string, unknown>): RetentionPolicy {
let obj: Record<string, unknown>; let obj: Record<string, unknown>;
@@ -77,7 +77,13 @@ export function retentionActive(policy: RetentionPolicy): boolean {
* snapshots by tag. Returns null for `none` (nothing to do). The tag filter uses * snapshots by tag. Returns null for `none` (nothing to do). The tag filter uses
* the STABLE per-config identity so no other config's snapshots enter the pool. * the STABLE per-config identity so no other config's snapshots enter the pool.
* `--group-by ''` because helper hostnames are random and would otherwise split * `--group-by ''` because helper hostnames are random and would otherwise split
* the group; `--prune` reclaims space; `dryRun` adds `--dry-run` for the wipe check. * the group; `dryRun` adds `--dry-run` for the wipe check.
*
* NO `--prune` here: post-backup retention only removes snapshot references (fast,
* metadata-only). `--prune` walks and repacks the whole repo (minutes to hours on a
* large or slow-backend repo), so it belongs in the destination's own prune schedule
* (default monthly), not inline on every backup. Same split as restic front-ends
* (backrest, zerobyte).
* *
* @param configTagFilter e.g. `dockhand:instance=<uuid>,dockhand:configid=<id>` * @param configTagFilter e.g. `dockhand:instance=<uuid>,dockhand:configid=<id>`
*/ */
@@ -91,18 +97,17 @@ export function buildForgetArgs(
const args = ['forget']; const args = ['forget'];
if (opts.json) args.push('--json'); if (opts.json) args.push('--json');
if (opts.dryRun) args.push('--dry-run'); if (opts.dryRun) args.push('--dry-run');
else args.push('--prune');
// `forget` needs an EXCLUSIVE repo lock; --retry-lock is how long it waits for a // `forget` needs an EXCLUSIVE repo lock; --retry-lock is how long it waits for a
// held lock before giving up. This runs inside Dockhand's per-repo serializer, so // held lock before giving up. This runs inside Dockhand's per-repo serializer, so
// no OTHER Dockhand op in THIS instance holds the lock — the only lock that can be // no OTHER Dockhand op in THIS instance holds the lock - the only lock that can be
// present is either (a) an ORPHAN from a backup helper that Dockhand force-removed // present is either (a) an ORPHAN from a backup helper that Dockhand force-removed
// mid-lock (its hostname is the dead container id, so restic won't age it out as // mid-lock (its hostname is the dead container id, so restic won't age it out as
// stale for 30min), or (b) a live lock from a SEPARATE Dockhand instance sharing // stale for 30min), or (b) a live lock from a SEPARATE Dockhand instance sharing
// the repo. In both cases a LONG wait is wrong: for (a) we'd block ~10min (5m × // the repo. In both cases a LONG wait is wrong: for (a) we'd block ~10min (5m x
// dry-run + 5m × real) on a dead owner — the actual cause of the 300s CI backup // dry-run + 5m x real) on a dead owner - the actual cause of the 300s CI backup
// timeouts; for (b) a short wait just means retention is skipped this run (prune // timeouts; for (b) a short wait just means retention is skipped this run (forget
// is non-fatal — the backup already succeeded — and it retries next schedule). // is non-fatal - the backup already succeeded - and it retries next schedule).
// So keep it short. A failed/timed-out prune becomes a WARNING, never fails the // So keep it short. A failed/timed-out forget becomes a WARNING, never fails the
// backup, and never touches another instance's lock. Overridable for slow links. // backup, and never touches another instance's lock. Overridable for slow links.
const retryLock = process.env.RESTIC_FORGET_RETRY_LOCK || '1m'; const retryLock = process.env.RESTIC_FORGET_RETRY_LOCK || '1m';
args.push('--retry-lock', retryLock, '--group-by', '', '--tag', configTagFilter); args.push('--retry-lock', retryLock, '--group-by', '', '--tag', configTagFilter);
+47 -14
View File
@@ -1,13 +1,11 @@
/** /**
* backups/security.ts — the security surface for the backup/restore rewrite. * backups/security.ts - the security surface for backup/restore.
* *
* These are the hardened, well-tested primitives that keep restic invocation, * Hardened, well-tested primitives that keep restic invocation, credential
* credential handling, and restore targets safe (env/flag/cloud allowlists, * handling, and restore targets safe (env/flag/cloud allowlists, SSRF guards,
* SSRF guards, at-rest encryption, path containment). We RE-EXPORT them from * at-rest encryption, path containment). RE-EXPORTED from their existing homes,
* their existing homes rather than re-deriving them: re-implementing a security * not re-derived: one import site for the whole domain, so a reviewer sees the
* allowlist from scratch is exactly where a rewrite reintroduces the bug the * security surface in one place.
* allowlist exists to prevent. One import site for the whole domain, so a
* reviewer sees the security surface in one place.
* *
* Nothing here has app-specific logic — it is a barrel plus two small pure guards * Nothing here has app-specific logic — it is a barrel plus two small pure guards
* the rewrite needs (restore-target containment + a snapshot-path allowlist). * the rewrite needs (restore-target containment + a snapshot-path allowlist).
@@ -25,8 +23,10 @@ export {
filterCloudEnvVars, filterCloudEnvVars,
/** Split + validate a destination's extra restic flags against an allowlist; /** Split + validate a destination's extra restic flags against an allowlist;
* throws on anything that could read/write arbitrary files or run code * throws on anything that could read/write arbitrary files or run code
* (`--option`, `--password-command`, `--cacert`, …). */ * (`--option`, `--password-command`, `--cacert`, …). Backup/global vs restore
* (restore adds --exclude-xattr). */
sanitizeResticFlags, sanitizeResticFlags,
sanitizeRestoreFlags,
/** True iff `id` is a syntactically valid restic snapshot id (hex, 8 or 64). /** True iff `id` is a syntactically valid restic snapshot id (hex, 8 or 64).
* The first gate before an id is interpolated into any restic argv. */ * The first gate before an id is interpolated into any restic argv. */
isValidSnapshotId, isValidSnapshotId,
@@ -92,15 +92,13 @@ export function timingSafeStrEqual(a: string, b: string): boolean {
* that isn't a plain `/volumes/<[A-Za-z0-9._-]+>` before it reaches the script. * that isn't a plain `/volumes/<[A-Za-z0-9._-]+>` before it reaches the script.
*/ */
const SAFE_VOLUME_INCLUDE = /^\/volumes\/[A-Za-z0-9._-]+$/; const SAFE_VOLUME_INCLUDE = /^\/volumes\/[A-Za-z0-9._-]+$/;
// A stack new-location restore also extracts the stored compose files under
// /metadata/stacks/<name>; allow that path with the same strict name char class.
const SAFE_STACK_INCLUDE = /^\/metadata\/stacks\/[A-Za-z0-9._-]+$/;
export function isSafeVolumeInclude(inc: string): boolean { export function isSafeVolumeInclude(inc: string): boolean {
// A config-only (metadata-only) snapshot has no volumes; its new-location // A config-only (metadata-only) snapshot has no volumes; its new-location
// restore extracts the whole stored /metadata dir. This is a fixed literal // restore extracts the whole stored /metadata dir. This is a fixed literal
// (never attacker-influenced), so allow it explicitly. // (never attacker-influenced), so allow it explicitly. The stack dir now rides
return inc === '/metadata' || SAFE_VOLUME_INCLUDE.test(inc) || SAFE_STACK_INCLUDE.test(inc); // /volumes/__dockhand_stackdir__ (a SAFE_VOLUME_INCLUDE), so no /metadata/stacks path.
return inc === '/metadata' || SAFE_VOLUME_INCLUDE.test(inc);
} }
/** Throwing variant — use at the boundary before building a restore script. */ /** Throwing variant — use at the boundary before building a restore script. */
@@ -133,6 +131,41 @@ export function assertTargetWithin(root: string, target: string, label = 'restor
} }
} }
/** System paths a new-location restore must NEVER write into. The restore helper runs as
* root and bind-mounts targetPath:targetPath:rw, then restic writes `targetPath/volumes/...`,
* so an unguarded targetPath is arbitrary host-dir write. A plain new-location restore is a
* legitimate "extract wherever the operator picked" feature, so we don't confine it to one
* root; we reject absolute-path escapes and well-known sensitive roots instead. */
const FORBIDDEN_RESTORE_ROOTS = [
'/', '/etc', '/root', '/boot', '/dev', '/proc', '/sys', '/run',
'/var/run', '/bin', '/sbin', '/lib', '/lib64', '/var/lib/docker',
];
/** Returns a reason string if `targetPath` is unsafe for a new-location restore, else null.
* Uses resolve() so `/etc/../etc`, `/srv/../etc`, trailing slashes all normalize before the
* forbidden-root compare — a string-only prefix check would miss those. */
export function unsafeRestoreTargetReason(targetPath: string): string | null {
const raw = (targetPath ?? '').trim();
if (!raw) return 'a target path is required';
if (!raw.startsWith('/')) return 'target path must be absolute';
if (raw.includes('\0')) return 'target path must not contain NUL';
const norm = resolvePath(raw); // collapses .. and trailing slashes
for (const root of FORBIDDEN_RESTORE_ROOTS) {
if (norm === root || norm.startsWith(root === '/' ? '/' : root + pathSep)) {
// `/` matches everything by the prefix rule; only flag `/` itself for that root.
if (root === '/' && norm !== '/') continue;
return `target path "${norm}" is inside a protected system directory (${root})`;
}
}
return null;
}
/** Throwing variant for the service layer (defense-in-depth behind API validation). */
export function assertSafeRestoreTarget(targetPath: string): void {
const reason = unsafeRestoreTargetReason(targetPath);
if (reason) throw new Error(`Refusing new-location restore: ${reason}`);
}
/** /**
* Reject a user-supplied name (stack/target) that could traverse the filesystem * Reject a user-supplied name (stack/target) that could traverse the filesystem
* when joined into a path. Empty or containing a path separator / `..` → invalid. * when joined into a path. Empty or containing a path separator / `..` → invalid.
+190
View File
@@ -0,0 +1,190 @@
/**
* The ONE typed contract for a snapshot's metadata.json. Capture PRODUCES a
* SnapshotLayout (buildSnapshotLayout + serializeLayout); restore CONSUMES it
* (parseSnapshotLayout). Both ends share this type, so a drift between what capture
* writes and what restore reads is a COMPILE error, not a silent `undefined`.
*
* Pure and import-light (no db/docker/fs) so it is unit-testable directly.
*/
/** Schema version of the metadata.json payload. Bump ONLY on a breaking shape change.
* Backups are beta -> v1 is the first version. A snapshot written before this contract
* has NO version field; the parser accepts a missing version as v1 (see parse) so beta
* testers' existing snapshots keep restoring. */
export const SNAPSHOT_LAYOUT_VERSION = 1 as const;
export type VolumeKind = 'volume' | 'bind';
/** One captured data channel. `key` is the /volumes/<key> segment restic lists and the
* restore UI indexes on. `source` is the bind's ABSOLUTE host path, or (for a named
* volume) the volume name - what an in-place restore rebinds to. */
export interface SnapshotVolume {
key: string;
name: string;
source: string;
type: VolumeKind;
}
/** A stack secret carried IN the snapshot: `value` is at-rest ciphertext (enc:v1:...),
* NEVER plaintext. materialise writes these to the DB on restore; the UI shows only the
* `key`s. */
export interface SnapshotSecret {
key: string;
value: string;
}
/** The stack half of a snapshot. Present iff type==='stack' AND a compose file was
* resolved at capture. The stack dir ALWAYS lives at /volumes/__dockhand_stackdir__ (the
* helper's host bind mount) -> no path is stored; restore uses stackDirSource(). */
/** One captured stack file, for browse/debug: relative path + byte size. */
export interface SnapshotStackFile {
path: string;
bytes: number;
}
export interface SnapshotStack {
composeFileName: string;
fileList: SnapshotStackFile[];
excludedBindDirs: string[];
secrets: SnapshotSecret[];
}
/** The typed metadata.json payload. This IS the snapshot contract. `hasStackFiles` is
* NOT a field here on purpose: it is DERIVED as `layout.stack !== undefined`, so a flag
* can never disagree with the actual captured stack. */
export interface SnapshotLayout {
version: typeof SNAPSHOT_LAYOUT_VERSION;
type: 'container' | 'stack';
targetName: string;
environmentId: number | null;
backupTime: string;
volumes: SnapshotVolume[];
/** Present only for a container backup (recreate-as-is). Opaque inspect JSON. */
container?: unknown;
/** Present only for a stack backup with a resolved compose. */
stack?: SnapshotStack;
}
/** Capture-side builder. The single place a SnapshotLayout is constructed - no bare
* `metadata.x = ...` bag anywhere else. */
export function buildSnapshotLayout(input: {
type: 'container' | 'stack';
targetName: string;
environmentId: number | null;
backupTime: string;
volumes: SnapshotVolume[];
container?: unknown;
stack?: SnapshotStack;
}): SnapshotLayout {
return { version: SNAPSHOT_LAYOUT_VERSION, ...input };
}
/** Serialize for writing to /metadata/metadata.json (pretty, matching the prior format). */
export function serializeLayout(layout: SnapshotLayout): string {
return JSON.stringify(layout, null, 2);
}
function isVolumeKind(v: unknown): v is VolumeKind {
return v === 'volume' || v === 'bind';
}
/** Parse+validate a raw metadata.json string into a typed SnapshotLayout, or null on ANY
* problem (bad JSON, wrong version, missing/wrong-typed required field). NEVER throws:
* restore turns null into an explicit, logged "metadata unreadable" branch instead of a
* cascade of undefineds. A missing `version` is treated as v1 (pre-contract beta
* snapshot). Unknown FUTURE versions return null (we can't safely read them). */
export function parseSnapshotLayout(raw: string): SnapshotLayout | null {
let o: any;
try { o = JSON.parse(raw); } catch { return null; }
if (!o || typeof o !== 'object') return null;
// Accept missing version (pre-contract beta) as v1; reject any other value.
const version = o.version === undefined ? SNAPSHOT_LAYOUT_VERSION : o.version;
if (version !== SNAPSHOT_LAYOUT_VERSION) return null;
if (o.type !== 'container' && o.type !== 'stack') return null;
if (typeof o.targetName !== 'string') return null;
const environmentId = typeof o.environmentId === 'number' ? o.environmentId : null;
const backupTime = typeof o.backupTime === 'string' ? o.backupTime : '';
// volumes[]: coerce defensively, drop any malformed entry rather than failing the whole
// parse (a snapshot with one odd volume entry should still restore the rest).
const volumes: SnapshotVolume[] = Array.isArray(o.volumes)
? o.volumes
.filter((v: any) => v && typeof v.key === 'string' && isVolumeKind(v.type))
.map((v: any) => ({
key: v.key as string,
name: typeof v.name === 'string' ? v.name : v.key,
source: typeof v.source === 'string' ? v.source : '',
type: v.type as VolumeKind,
}))
: [];
let stack: SnapshotStack | undefined;
if (o.stack && typeof o.stack === 'object' && typeof o.stack.composeFileName === 'string') {
stack = {
composeFileName: o.stack.composeFileName,
fileList: Array.isArray(o.stack.fileList)
? o.stack.fileList
.filter((f: any) => f && typeof f.path === 'string' && typeof f.bytes === 'number')
.map((f: any) => ({ path: f.path as string, bytes: f.bytes as number }))
: [],
excludedBindDirs: Array.isArray(o.stack.excludedBindDirs) ? o.stack.excludedBindDirs.filter((x: any) => typeof x === 'string') : [],
secrets: Array.isArray(o.stack.secrets)
? o.stack.secrets
.filter((s: any) => s && typeof s.key === 'string' && typeof s.value === 'string')
.map((s: any) => ({ key: s.key as string, value: s.value as string }))
: [],
};
}
return {
version: SNAPSHOT_LAYOUT_VERSION,
type: o.type,
targetName: o.targetName,
environmentId,
backupTime,
volumes,
container: o.container,
stack,
};
}
/** Shape returned to the client: same as SnapshotLayout but with all secret-bearing
* fields removed. `container` inspect keeps its shape MINUS Config.Env/Labels; stack
* secrets collapse to names. */
export type RedactedSnapshotLayout = Omit<SnapshotLayout, 'container' | 'stack'> & {
container?: unknown;
hasStackFiles: boolean;
stack?: (Omit<SnapshotStack, 'secrets'> & { secretKeys: string[] });
};
/** SINGLE redaction point for any snapshot metadata that reaches the client (the
* metadata route AND the dump route serving metadata.json). Strips:
* - stack.secrets VALUES (at-rest ciphertext) -> secretKeys names only
* - container inspect Config.Env (plaintext env vars: DB passwords, API keys, tokens)
* and Config.Labels (compose can stash secrets in labels)
* The full inspect is kept server-side for recreate; it must NEVER leave the process
* un-redacted. `backups:view` is a weaker grant than `containers:view`, so leaking the
* inspect here would defeat RBAC secret-hiding. */
export function redactSnapshotLayout(metadata: SnapshotLayout): RedactedSnapshotLayout {
const { stack, container, ...rest } = metadata;
return {
...rest,
hasStackFiles: stack !== undefined,
container: redactContainerInspect(container),
stack: stack
? { ...stack, secrets: undefined as never, secretKeys: stack.secrets.map((s) => s.key) } as any
: undefined,
} as RedactedSnapshotLayout;
}
/** Remove secret-bearing keys from an opaque Docker inspect object (Config.Env, Config.Labels).
* Returns the value untouched if it isn't the expected shape (defensive - never throw). */
function redactContainerInspect(container: unknown): unknown {
if (!container || typeof container !== 'object') return container;
const c = container as any;
if (!c.Config || typeof c.Config !== 'object') return container;
const { Env, Labels, ...configRest } = c.Config;
return { ...c, Config: { ...configRest, Env: undefined, Labels: undefined } };
}
+286
View File
@@ -0,0 +1,286 @@
/**
* stackdir-plan.ts — the ONE place that decides WHICH host folder is captured as a
* stack's directory. Pure and import-light (no db/docker/fs), so it is unit-testable
* directly and can't drift into scattered `connectionType` branches.
*
* MAIN FLOW: the backup helper bind-mounts the stack folder from the TARGET env's HOST at
* /volumes/__dockhand_stackdir__:ro, and restic reads it from disk - the helper is a sibling
* container on the target daemon, so a host path on that daemon is mountable. The snapshot
* reflects the stack folder AS IT IS ON THE HOST (what the user actually edits).
*
* ONE EXCEPTION - a direct-REMOTE env with NO remote_stacks_dir: nothing is staged on the host
* (deploy went via stdin), so there is no host folder to mount. The caller then tars the
* compose/config from Dockhand's OWN local copy into the SAME snapshot location instead of
* hard-failing. This is the only case that doesn't reflect the host 1:1 - because the host has
* nothing to reflect.
*
* This module resolves the CANDIDATE host path (see resolveHostStackDir). A runtime probe
* (helper bind + `test -f <composeFile>`) then proves the path is real on the target daemon
* before capture; if it isn't (e.g. a direct env whose working_dir is Dockhand's own
* /app/data path, not the host's), capture HARD-FAILS to data-only rather than silently
* backing up an empty auto-created dir.
*/
/** The reserved /volumes key the stack dir is captured under. Long and namespaced so a real
* user volume can't realistically collide with it. */
export const STACKDIR_VOLUME_KEY = '__dockhand_stackdir__';
/**
* True for a reserved /volumes key that is NOT a user data volume — currently just the
* stack-dir capture. Restore uses it to keep this key OUT of everything that treats a
* /volumes/ entry as restorable container data (the volume picker, "restore all", the
* in-place swap). The stack dir is restored ONLY via the redeploy/materialise path.
*/
export function isReservedVolumeKey(key: string): boolean {
return key === STACKDIR_VOLUME_KEY;
}
/**
* Where a snapshot's captured stack dir lives, for restore to read it back: ALWAYS
* /volumes/__dockhand_stackdir__. `include` is for `restic restore --include`; `extractSub`
* is the on-disk subpath the restore lands under its --target.
*/
export function stackDirSource(): { include: string; extractSub: string } {
return { include: `/volumes/${STACKDIR_VOLUME_KEY}`, extractSub: `volumes/${STACKDIR_VOLUME_KEY}` };
}
/**
* Derive the host stack dir from a RELATIVE compose bind and the host path the daemon reports
* for it. This is the MOST AUTHORITATIVE source: the Docker daemon tells us exactly where the
* bind lives on its host (`mount.Source`), and compose resolves a relative source (`./html`)
* against the stack dir - so `hostStackDir = <mount.Source> with the relative tail removed`.
*
* Example: compose `./html:/x` on host `/docker/data/dockhand/stacks/anton/pppppp` gives the
* daemon `mount.Source = /docker/data/dockhand/stacks/anton/pppppp/html`; with relSource
* `./html` we strip `/html` and get the stack dir. Works on socket, direct-local, adopted, and
* hawser alike (the daemon always knows its own bind sources), and needs no DATA_DIR/HOST_DATA_DIR
* config. Returns null when the source isn't relative or the tail doesn't match (absolute bind,
* `${VAR}` source, etc.) - the caller then falls back to translation/label.
*
* @param relSource the compose bind source as written (e.g. `./html`, `./`, `../x`)
* @param hostSource the absolute host path the daemon reports for that bind (`mount.Source`)
*/
export function hostStackDirFromBind(relSource: string, hostSource: string): string | null {
if (typeof relSource !== 'string' || typeof hostSource !== 'string') return null;
const rs = relSource.trim();
const hs = hostSource.trim().replace(/\/+$/, '');
if (!hs.startsWith('/')) return null; // host source must be absolute
if (!/^\.\.?\//.test(rs) && rs !== '.' && rs !== './') return null; // only relative sources
// The relative tail below the stack dir: `./html` -> `html`, `./` or `.` -> '' (bind IS the
// stack dir). Reject `../` sources: the bind is ABOVE the stack dir, so we can't derive it.
let tail = rs.replace(/^\.\//, '').replace(/\/+$/, '');
if (tail === '.' || tail === '') {
return hs; // the whole stack dir is bind-mounted (`./:/app`) -> host source IS the stack dir
}
if (tail.startsWith('../') || tail === '..') return null;
// Strip the `/tail` suffix from the host source. Must actually end with it, or the mapping
// is inconsistent (don't guess).
const suffix = '/' + tail;
if (!hs.endsWith(suffix)) return null;
const dir = hs.slice(0, hs.length - suffix.length);
return dir === '' ? '/' : dir;
}
/**
* Derive the host stack dir by matching the compose's relative bind DIRS against the daemon's
* reported bind sources. For each relative dir the compose declares (`html`, from `./html:/x`),
* find a discovered bind whose host source ENDS WITH `/html` and strip that tail - the remainder
* is the stack dir. Returns the first consistent match, or null if none. Pure + unit-testable.
*
* `relBindDirs`: relative dir names from relativeBindDirsFromCompose (e.g. ['html','data']).
* `bindSources`: the daemon-reported host source paths of the stack's bind mounts (mount.Source).
*/
export function deriveStackDirFromBinds(relBindDirs: string[], bindSources: string[]): string | null {
for (const rel of relBindDirs) {
const tail = '/' + rel.replace(/^\/+|\/+$/g, '');
for (const src of bindSources) {
const derived = hostStackDirFromBind('./' + rel.replace(/^\/+/, ''), src);
if (derived) return derived;
// Also handle a nested relative dir (`./conf/nginx`) whose source ends with the tail.
const hs = typeof src === 'string' ? src.trim().replace(/\/+$/, '') : '';
if (hs.endsWith(tail)) { const d = hs.slice(0, hs.length - tail.length); return d === '' ? '/' : d; }
}
}
return null;
}
/**
* Whether a bind-derived host path can be TRUSTED for this env. bind-derived comes from the
* target daemon's reported mount.Source, which is a REAL host path for socket / direct-LOCAL /
* adopted / hawser (the daemon shares its host with the stack). But for a direct-REMOTE env with
* NO remote_stacks_dir, the stack was deployed VIA STDIN with working_dir = Dockhand's own
* container path; `docker compose` then asks the remote daemon to bind `./data`, and the daemon
* mkdir's a PHANTOM EMPTY dir at that Dockhand-container path on ITS host. Stripping the tail
* yields a dir that exists remotely but holds NO compose (never staged) -> the probe HARD-FAILS.
* So distrust bind-derived in exactly that case, letting the caller fall through to kind:'tar'
* (capture the compose/config from Dockhand's own local copy). When remote_stacks_dir IS set the
* deploy staged the files there and bind-derived pins the real path, so keep it. Pure + testable.
*/
export function trustBindDerivedForEnv(
bindDerivedHostPath: string | null,
opts: { directRemote: boolean; hasRemoteStacksDir: boolean }
): string | null {
if (opts.directRemote && !opts.hasRemoteStacksDir) return null;
return bindDerivedHostPath;
}
export type StackDirEntry = { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' };
/**
* Tag each stack-dir entry that is ALSO captured as its own volume/bind channel, so the picker
* can show it as non-deselectable ("captured as a bind mount below") - the user controls it in
* the Volumes section, not here. An entry is a bind when EITHER `<hostPath>/<name>` matches a
* daemon-reported bind source (runtime) OR its name is a compose relative-bind dir. The compose
* source is load-bearing: `bindSources` comes from inspecting the RUNNING containers, so a STOPPED
* stack reports none and a bind dir (e.g. `./memos`) would wrongly show as a plain, deselectable
* stack file. `bindDirNames` (from the compose, always available) fixes that. Pure + unit-testable.
*/
export function tagCapturedEntries(entries: StackDirEntry[], hostPath: string, bindSources: string[], bindDirNames: string[] = []): StackDirEntry[] {
const base = hostPath.replace(/\/+$/, '');
const bindSet = new Set(bindSources.map((s) => (typeof s === 'string' ? s.replace(/\/+$/, '') : '')));
const nameSet = new Set(bindDirNames);
return entries.map((e) =>
(bindSet.has(`${base}/${e.name}`) || nameSet.has(e.name)) ? { ...e, capturedAs: 'bind' as const } : e
);
}
/**
* Parse the probe container's directory listing. The probe runs
* `find /probe -maxdepth 1 -mindepth 1 -printf '%y\t%s\t%f\n'`, so each line is
* `<type>\t<size>\t<name>` where type is 'd' (dir) / 'f' (file) / other. Pure + unit-testable.
* Names may contain spaces but not tabs or newlines (find's %f), so tab-splitting is safe.
* Dirs sort before files, then alphabetical.
*/
export function parseProbeListing(stdout: string): StackDirEntry[] {
const out: StackDirEntry[] = [];
for (const line of stdout.split('\n')) {
if (!line) continue;
const tab1 = line.indexOf('\t');
const tab2 = line.indexOf('\t', tab1 + 1);
if (tab1 < 0 || tab2 < 0) continue;
const t = line.slice(0, tab1);
const size = parseInt(line.slice(tab1 + 1, tab2), 10);
const name = line.slice(tab2 + 1);
if (!name) continue;
out.push({ name, type: t === 'd' ? 'dir' : 'file', size: Number.isFinite(size) ? size : 0 });
}
return out.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
}
/** Sentinel the probe list-script prints when the target path does NOT exist, so the classifier can
* tell "helper ran, dir missing" apart from "helper ran, dir empty" (both otherwise list nothing). */
export const PROBE_MISSING_SENTINEL = '__PROBE_MISSING__';
/** Data-presence verdict for a probed host path / volume. `helper-failed` (the container could not
* run at all) is decided by the CALLER catching the run error - the classifier only sees stdout of
* a helper that DID run, so it returns has-data / empty / missing. */
export type ProbeDataKind = 'has-data' | 'empty' | 'missing' | 'helper-failed';
/** Classify a probe helper's stdout (pure, unit-testable). The list-script prints the missing
* sentinel when the path doesn't exist, one `<type>\t<size>\t<name>` line per entry otherwise. */
export function classifyProbeListing(stdout: string): 'has-data' | 'empty' | 'missing' {
if (stdout.includes(PROBE_MISSING_SENTINEL)) return 'missing';
return parseProbeListing(stdout).length > 0 ? 'has-data' : 'empty';
}
/**
* Is the TARGET daemon Dockhand's OWN host? Only then are the DATA_DIR / container-mount
* translations valid: they map a container path to DOCKHAND'S host (via HOST_DATA_DIR /
* Dockhand's own mounts), which is the WRONG host for a remote daemon. For a remote env the
* stack lives on a DIFFERENT machine, so those translations would hand the helper a path that
* only exists on Dockhand's host, not the remote one. Pure + unit-testable.
* - socket / null: local (Dockhand shares the daemon's host).
* - direct: local ONLY when the env's tcp host == Dockhand's own docker host (#1203 proof).
* - hawser: never local.
*/
export function isLocalDaemon(connectionType: string | null, envTcpHost: string | null, ownDockerHost: string | null): boolean {
if (connectionType && connectionType.startsWith('hawser')) return false;
if (connectionType === 'socket' || connectionType == null) return true;
if (connectionType === 'direct') return !!(ownDockerHost && envTcpHost && ownDockerHost === envTcpHost);
return false;
}
/** Result of resolving the candidate host stack folder (before the runtime probe). */
export type HostStackDirResolution =
| { kind: 'candidate'; hostPath: string; composeFile: string; source: string }
| { kind: 'unknown'; reason: string };
/** Inputs for the pure host-path resolution. All the I/O (compose lookup, DATA_DIR/mount
* translation, label read) is done by the CALLER and injected here, so this stays pure and
* unit-testable. The caller passes whichever candidates it could compute; this picks the
* right one in priority order. */
export interface HostStackDirInput {
/** basename of Dockhand's compose file for the stack (immich.yaml / docker-compose.yml).
* The AUTHORITATIVE compose name - NOT the config_files label, which is `-` when Dockhand
* deploys via stdin (`-f -`). */
composeFileName: string | null;
/** For a direct-REMOTE env with remote_stacks_dir set: the explicit, user-declared host
* path (<remote_stacks_dir>/<stack>) where the deploy staged the files. Highest priority
* after a bind pins the dir more exactly. Null for socket/hawser/local. */
remoteStacksDirHostPath?: string | null;
/** Host stack dir DERIVED from a relative compose bind's daemon-reported source
* (hostStackDirFromBind). The MOST AUTHORITATIVE source - the daemon knows exactly where its
* bind lives on the host, so this needs no DATA_DIR/HOST_DATA_DIR config and works on socket,
* direct-local, adopted, and hawser. Null when the stack has no usable relative bind. */
bindDerivedHostPath: string | null;
/** Dockhand's stack dir translated to the host via DATA_DIR -> HOST_DATA_DIR. Non-null
* (and != the container path) when the stack lives under Dockhand's DATA_DIR and the host
* data dir is known - the SOCKET/LOCAL Dockhand-deployed case. Fallback when no bind. */
dataDirHostPath: string | null;
/** Dockhand's stack dir translated to the host via a container bind mount, for an
* adopted/external stack that lives OUTSIDE DATA_DIR but on a mounted host path. */
mountHostPath: string | null;
/** com.docker.compose.project.working_dir label. For a HAWSER agent (which ran compose on
* the remote host) or a matching-paths direct env, this IS the real host path. Used only
* when neither translation applies (the daemon is not Dockhand's own host). */
workingDirLabel: string | null;
}
/**
* Resolve the CANDIDATE host stack folder. ONE FLOW still: the helper always bind-mounts this
* path from the target daemon's host. The subtlety is that "where is the stack folder ON THAT
* DAEMON'S HOST" differs by wiring, and the compose `working_dir` label is NOT reliable on its
* own (it is the container-view path for a Dockhand-deployed stack, and `config_files` is `-`
* for stdin deploys). So the caller computes the candidates and we pick in priority order:
* 1. `bindDerivedHostPath` - derived from a relative bind's daemon-reported source. The daemon
* knows exactly where its bind lives on the host, so this is authoritative and needs no
* DATA_DIR config. Works on socket / direct-local / adopted / hawser.
* 2. `dataDirHostPath` - Dockhand deployed the stack under DATA_DIR and we know the host data
* dir (SOCKET/LOCAL). The label `/app/data/...` would be the CONTAINER path; the helper on
* the host needs `/docker/data/dockhand/...`.
* 3. `mountHostPath` - adopted/external stack outside DATA_DIR but on a mounted host path.
* 4. `workingDirLabel` - HAWSER (agent ran compose on the remote host, label = host path) or
* matching-paths direct. Only when nothing above applied.
* 5. none -> unknown (caller HARD-FAILS a stack backup; option A).
* A runtime probe (`test -f <composeFile>` in the helper) then CONFIRMS reachability before
* capture. Pure + unit-testable. */
export function resolveHostStackDir(input: HostStackDirInput): HostStackDirResolution {
// The probe's `test -f` needs a real compose name. `config_files` is `-` for stdin deploys,
// so we take the name from Dockhand's own compose file (composeFileName), falling back to the
// conventional name.
const composeFile = input.composeFileName && input.composeFileName !== '-'
? input.composeFileName
: 'docker-compose.yml';
const norm = (p: string | null | undefined): string => (typeof p === 'string' ? p.trim().replace(/\/+$/, '') : '');
const viaBind = norm(input.bindDerivedHostPath);
if (viaBind) return { kind: 'candidate', hostPath: viaBind, composeFile, source: 'derived from a relative compose bind (daemon-reported host source)' };
const viaRemoteDir = norm(input.remoteStacksDirHostPath);
if (viaRemoteDir) return { kind: 'candidate', hostPath: viaRemoteDir, composeFile, source: 'declared host stack path (direct-remote remote_stacks_dir / hawser agent STACKS_DIR)' };
const viaData = norm(input.dataDirHostPath);
if (viaData) return { kind: 'candidate', hostPath: viaData, composeFile, source: 'DATA_DIR -> HOST_DATA_DIR translation (Dockhand-deployed, local)' };
const viaMount = norm(input.mountHostPath);
if (viaMount) return { kind: 'candidate', hostPath: viaMount, composeFile, source: 'container mount translation (adopted/external stack)' };
const wd = norm(input.workingDirLabel);
if (wd) return { kind: 'candidate', hostPath: wd, composeFile, source: 'compose working_dir label (hawser agent / matching paths)' };
return { kind: 'unknown', reason: 'could not locate the stack folder on the host (no DATA_DIR/mount translation and no working_dir label)' };
}
+26 -30
View File
@@ -1,44 +1,35 @@
/** /**
* backups/swap.ts — the shell scripts that make an in-place volume restore safe. * backups/swap.ts — the shell scripts that make an in-place volume restore safe.
* PURE string builders (no I/O), so the commit protocol is unit-testable against a
* real temp filesystem.
* *
* These are PURE string builders (no I/O), so the exact commit protocol can be * An in-place restore replaces a live volume's contents with a snapshot's. Deleting
* unit-tested by running the generated shell against a real temp filesystem. * live data then copying the restore in is a data-loss trap: a mid-copy failure
* * leaves an empty volume reported as success. Instead: restore ON THE SIDE first,
* THE PROBLEM. An in-place restore must replace the live contents of a volume * then swap by moving directory entries, never deleting the original until the
* with a snapshot's contents. The naive "delete the live data, then copy the * replacement is committed. Protocol per volume:
* restored data in" is a silent-data-loss trap: if the copy fails partway (disk
* full, permission, a mid-copy crash) the original is already gone and the
* restore reports success over an empty volume.
*
* THE DESIGN. Restore ON THE SIDE first, then swap by moving directory entries —
* never deleting the original until the replacement is committed:
* *
* phase 0 recover any interrupted PRIOR swap (see recovery script) — never * phase 0 recover any interrupted PRIOR swap (see recovery script) — never
* blindly delete `.dockhand-restore-old`, it may be the only copy of * blindly delete `.dockhand-restore-old`, it may be the only copy.
* the original data. * phase 1 restic restores into `<live>/.dockhand-restore-new` - a staging dir ON
* phase 1 restic restores into `<live>/.dockhand-restore-new` — a staging dir * THE LIVE VOLUME'S OWN FILESYSTEM. Failure-prone; if it fails, live data
* ON THE LIVE VOLUME'S OWN FILESYSTEM. This is the failure-prone step; * is untouched.
* if it fails the live data is still completely untouched.
* marker 2 move the live entries aside into `.dockhand-restore-old` * marker 2 move the live entries aside into `.dockhand-restore-old`
* (same-filesystem renames — fast, and reversible). * (same-filesystem renames - fast and reversible).
* marker 3 move the restored entries from `.dockhand-restore-new` up into the * marker 3 move the restored entries from `.dockhand-restore-new` up into the
* live root (same-filesystem renames). * live root (same-filesystem renames).
* done drop the marker and delete `.dockhand-restore-old` — the ONLY point * done drop the marker and delete `.dockhand-restore-old` — the ONLY point
* at which the original data is deleted, and only after the new data * at which the original is deleted, and only after the new data is in place.
* is fully in place.
* *
* A same-filesystem `mv` of a directory entry is atomic. The move loops operate * A same-filesystem `mv` of a directory entry is atomic; the loops move TOP-LEVEL
* on TOP-LEVEL entries (not a recursive per-file copy), so the window is a * entries (not a recursive per-file copy), so the window is a handful of renames.
* handful of atomic renames rather than a long byte copy.
* *
* THE CRASH CASE (the one thing fail-fast cannot cover). An external SIGKILL * Crash case fail-fast cannot cover: an external SIGKILL (timeout, daemon restart,
* (restore timeout, daemon restart, reboot, OOM) can land BETWEEN the marker-2 * reboot, OOM) can land BETWEEN the marker-2 and marker-3 loops, leaving the live
* and marker-3 move loops, leaving the live root partly emptied with the * root partly emptied with originals in `.dockhand-restore-old`. The phase marker
* originals in `.dockhand-restore-old`. The phase marker file makes that state * makes that state detectable; the recovery script rolls it back. Recovery runs at
* detectable, and the recovery script rolls it back to the original content. * the start of every restore AND on startup for a dead-process restore, so a
* This recovery runs at the start of every restore AND on startup for a restore * half-swapped state is always reconciled before anything else touches the volume.
* whose process died — so the half-swapped state is always reconciled before
* anything else touches the volume.
*/ */
/** Basenames of the swap artifacts created inside a live volume root. Exported /** Basenames of the swap artifacts created inside a live volume root. Exported
@@ -101,8 +92,13 @@ function recoverOne(live: string): string {
const newQ = sq(`${live}/${SWAP_NEW}`); const newQ = sq(`${live}/${SWAP_NEW}`);
const oldQ = sq(`${live}/${SWAP_OLD}`); const oldQ = sq(`${live}/${SWAP_OLD}`);
const phaseQ = sq(`${live}/${SWAP_PHASE}`); const phaseQ = sq(`${live}/${SWAP_PHASE}`);
// Narrate ONLY when a marker is present (an interrupted prior swap is actually
// being rolled back), so a normal restore stays quiet but a re-run after a crash
// tells the user their volume is being recovered. Streamed to the restore log.
const say = (m: string) => `echo ${sq(`[dockhand] ${m}`)} >&2`;
return [ return [
`if [ -f ${phaseQ} ]; then`, `if [ -f ${phaseQ} ]; then`,
` ${say(`Recovering ${live}: rolling back an interrupted previous restore`)};`,
` if [ "$(cat ${phaseQ})" = "3" ]; then ${removeEntries(liveQ)}; fi;`, ` if [ "$(cat ${phaseQ})" = "3" ]; then ${removeEntries(liveQ)}; fi;`,
` if [ -d ${oldQ} ]; then ${moveEntries(oldQ, liveQ)}; fi;`, ` if [ -d ${oldQ} ]; then ${moveEntries(oldQ, liveQ)}; fi;`,
` rm -rf ${newQ} ${oldQ}; rm -f ${phaseQ};`, ` rm -rf ${newQ} ${oldQ}; rm -f ${phaseQ};`,
+13 -1
View File
@@ -9,7 +9,7 @@
* what's wrong with the request/config itself. * what's wrong with the request/config itself.
*/ */
import { isValidCron } from '../scheduler/cron-utils'; import { isValidCron } from '../scheduler/cron-utils';
import { isValidSnapshotId, isSafePathSegment } from './security'; import { isValidSnapshotId, isSafePathSegment, unsafeRestoreTargetReason } from './security';
import { parseRetention } from './retention'; import { parseRetention } from './retention';
/** A single validation problem: which field, and what's wrong. */ /** A single validation problem: which field, and what's wrong. */
@@ -138,6 +138,13 @@ export function validateRestoreRequest(input: RestoreRequestInput): ValidationRe
if (input.mode === 'new-location' && !isClone && (!input.targetPath || !input.targetPath.trim())) { if (input.mode === 'new-location' && !isClone && (!input.targetPath || !input.targetPath.trim())) {
issues.push({ field: 'targetPath', message: 'a target path is required for a new-location restore' }); issues.push({ field: 'targetPath', message: 'a target path is required for a new-location restore' });
} }
// Containment: whenever a targetPath is supplied for a new-location restore it must not
// point at a protected system dir (the helper mounts it rw as root and restic writes into
// it). This is the guarantee validate.ts documents ("validated for containment by the service").
if (input.mode === 'new-location' && input.targetPath && input.targetPath.trim()) {
const reason = unsafeRestoreTargetReason(input.targetPath);
if (reason) issues.push({ field: 'targetPath', message: reason });
}
if (isClone) { if (isClone) {
const mappedVols = new Set((input.volumeDestinations ?? []).map((d) => d.volume)); const mappedVols = new Set((input.volumeDestinations ?? []).map((d) => d.volume));
const looseCount = (input.volumes ?? []).filter((v) => !mappedVols.has(v)).length; const looseCount = (input.volumes ?? []).filter((v) => !mappedVols.has(v)).length;
@@ -160,6 +167,11 @@ export function validateRestoreRequest(input: RestoreRequestInput): ValidationRe
if (d.kind === 'path') { if (d.kind === 'path') {
if (typeof d.target !== 'string' || !d.target.startsWith('/') || d.target.includes('..')) { if (typeof d.target !== 'string' || !d.target.startsWith('/') || d.target.includes('..')) {
issues.push({ field: 'volumeDestinations', message: `path destination for "${d.volume}" must be an absolute path (no "..")` }); issues.push({ field: 'volumeDestinations', message: `path destination for "${d.volume}" must be an absolute path (no "..")` });
} else {
// Same protected-root guard as targetPath: the helper mounts this path rw as root
// and restic writes into it, so a clone must never target /etc, /var/run, etc.
const reason = unsafeRestoreTargetReason(d.target);
if (reason) issues.push({ field: 'volumeDestinations', message: `path destination for "${d.volume}": ${reason}` });
} }
} else if (d.kind === 'volume') { } else if (d.kind === 'volume') {
if (typeof d.target !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(d.target)) { if (typeof d.target !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(d.target)) {
-12
View File
@@ -164,18 +164,6 @@ export async function startScheduler(): Promise<void> {
console.warn(`[Scheduler] Orphan helper reap failed: ${err instanceof Error ? err.message : String(err)}`); console.warn(`[Scheduler] Orphan helper reap failed: ${err instanceof Error ? err.message : String(err)}`);
} }
// New backup/restore engine: roll back any restore swap it left interrupted,
// then scrub operations left `running` by a dead process. Runs after the orphan
// helper reap above; it recovers swaps recorded by the engine's journal.
try {
const { reconcileOnStartup } = await import('../backups');
const { swapsRolledBack, containersRestarted, opsScrubbed } = await reconcileOnStartup();
if (swapsRolledBack > 0) console.log(`[Scheduler] Rolled back ${swapsRolledBack} interrupted restore swap(s)`);
if (containersRestarted > 0) console.log(`[Scheduler] Restarted ${containersRestarted} target(s) left stopped for backup/restore`);
if (opsScrubbed > 0) console.log(`[Scheduler] Scrubbed ${opsScrubbed} interrupted operation(s)`);
} catch (err) {
console.warn(`[Scheduler] Backup engine startup reconciliation failed: ${err instanceof Error ? err.message : String(err)}`);
}
} }
// Get cron expressions and default timezone from database // Get cron expressions and default timezone from database
+48
View File
@@ -0,0 +1,48 @@
/**
* Pump a web ReadableStream into a node Writable (an http/https request) WITH backpressure.
* Import-light (no server deps) so it's unit-testable.
*
* The bug this guards against: a naive `while (read()) req.write(value)` ignores write()'s
* return value. write() returns false when the socket send-buffer is full; without awaiting
* 'drain' before the next read, a fast disk source feeding a slow (mTLS) peer piles the WHOLE
* payload into RAM (O(n) - measured 500MB -> +564MB RSS). Awaiting 'drain' bounds RAM to the
* socket high-water mark (O(1), +5MB), matching what node's .pipe() does for free on the
* socket/plain-http path.
*/
/** Minimal writable surface we depend on (http.ClientRequest satisfies this). */
export interface BackpressureWritable {
write(chunk: Uint8Array): boolean;
end(): void;
destroy(err?: Error): void;
once(event: 'drain', cb: () => void): void;
}
/** Minimal reader surface (a ReadableStreamDefaultReader satisfies this). */
export interface ChunkReader {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
}
/** Await a single 'drain' from the writable. */
function waitDrain(w: BackpressureWritable): Promise<void> {
return new Promise((resolve) => w.once('drain', resolve));
}
/**
* Read every chunk and write it, awaiting 'drain' whenever write() signals backpressure.
* Calls `end()` on completion. On any error, `destroy(err)` is called and the error rethrown.
*/
export async function pumpWebStreamToWritable(reader: ChunkReader, w: BackpressureWritable): Promise<void> {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value === undefined) continue;
if (!w.write(value)) await waitDrain(w);
}
w.end();
} catch (err) {
w.destroy(err as Error);
throw err;
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Pure tally + summary for the "Test all destinations" action. A destination test
* has three outcomes, not two: a reachable-but-uninitialized repo is `needs_init`,
* NOT a failure. Kept dependency-free so it's unit-testable without the DOM.
*/
export type TestOutcome = 'success' | 'needs_init' | 'failed';
export interface TestCounts {
passed: number;
failed: number;
needsInit: number;
}
/** Count each three-way test outcome. Anything not 'success'/'needs_init' is a failure. */
export function summarizeTestResults(outcomes: TestOutcome[]): TestCounts {
const counts: TestCounts = { passed: 0, failed: 0, needsInit: 0 };
for (const o of outcomes) {
if (o === 'success') counts.passed++;
else if (o === 'needs_init') counts.needsInit++;
else counts.failed++;
}
return counts;
}
/**
* Toast text + severity for a tally. Real failures make it an error; needs-init
* without failures is a warning (action needed, but nothing is broken); all-pass
* is a success.
*/
export function formatTestSummary(counts: TestCounts): {
text: string;
severity: 'success' | 'warning' | 'error';
} {
const { passed, failed, needsInit } = counts;
const parts: string[] = [`${passed} passed`];
if (failed > 0) parts.push(`${failed} failed`);
if (needsInit > 0) parts.push(`${needsInit} need init`);
if (failed > 0) return { text: parts.join(', '), severity: 'error' };
if (needsInit > 0) return { text: parts.join(', '), severity: 'warning' };
return { text: `All ${passed} destinations tested & stats collected`, severity: 'success' };
}
+5 -6
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackup } from '$lib/server/audit'; import { auditBackup } from '$lib/server/audit';
import { import {
getBackupConfigs, getBackupConfigs,
@@ -12,9 +13,8 @@ import { validateRetention, retentionToStore } from '$lib/server/backups/helpers
export const GET: RequestHandler = async ({ url, cookies }) => { export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) { const denied = await requireBackups(auth, 'view');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const type = url.searchParams.get('type') ?? undefined; const type = url.searchParams.get('type') ?? undefined;
const targetName = url.searchParams.get('target') ?? undefined; const targetName = url.searchParams.get('target') ?? undefined;
@@ -45,9 +45,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const { request, cookies } = event; const { request, cookies } = event;
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) { const denied = await requireBackups(auth, 'manage');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const body = await request.json(); const body = await request.json();
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit'; import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db'; import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
import { initRepository } from '$lib/server/backups'; import { initRepository } from '$lib/server/backups';
@@ -8,9 +9,8 @@ import { initRepository } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const { params, cookies } = event; const { params, cookies } = event;
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) { const denied = await requireBackups(auth, 'manage');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit'; import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db'; import { getBackupDestination } from '$lib/server/db';
import { rotateDestinationPassword } from '$lib/server/backups'; import { rotateDestinationPassword } from '$lib/server/backups';
@@ -20,9 +21,8 @@ import { rotateDestinationPassword } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event; const { params, request, cookies } = event;
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) { const denied = await requireBackups(auth, 'manage');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { import {
getBackupDestination, getBackupDestination,
updateBackupDestinationTestStatus updateBackupDestinationTestStatus
@@ -9,9 +10,8 @@ import { testRepository } from '$lib/server/backups';
export const POST: RequestHandler = async ({ params, cookies }) => { export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) { const denied = await requireBackups(auth, 'manage');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit'; import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db'; import { getBackupDestination } from '$lib/server/db';
import { verifyBackup } from '$lib/server/backups'; import { verifyBackup } from '$lib/server/backups';
@@ -9,9 +10,8 @@ import { createJobResponse } from '$lib/server/sse';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event; const { params, request, cookies } = event;
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) { const denied = await requireBackups(auth, 'manage');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const destinationId = parseInt(params.id); const destinationId = parseInt(params.id);
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 }); if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditRestore } from '$lib/server/audit'; import { auditRestore } from '$lib/server/audit';
import { cancelRestore } from '$lib/server/backups'; import { cancelRestore } from '$lib/server/backups';
import { isValidSnapshotId } from '$lib/server/backups/helpers'; import { isValidSnapshotId } from '$lib/server/backups/helpers';
@@ -14,9 +15,8 @@ import { isValidSnapshotId } from '$lib/server/backups/helpers';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const { request, cookies } = event; const { request, cookies } = event;
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) { const denied = await requireBackups(auth, 'manage');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const snapshotId: string | undefined = body.snapshotId; const snapshotId: string | undefined = body.snapshotId;
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation'; import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackup } from '$lib/server/audit'; import { auditBackup } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db'; import { getBackupDestination } from '$lib/server/db';
import { forgetSnapshot } from '$lib/server/backups'; import { forgetSnapshot } from '$lib/server/backups';
@@ -10,9 +11,8 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const DELETE: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event; const { params, url, cookies } = event;
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) { const rbacDenied = await requireBackups(auth, 'manage');
return json({ error: 'Permission denied' }, { status: 403 }); if (rbacDenied) return rbacDenied;
}
const snapshotId = params.id; const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId); const invalidSnap = validateSnapshotId(snapshotId);
@@ -2,14 +2,14 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation'; import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { browseSnapshot } from '$lib/server/backups'; import { browseSnapshot } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ params, url, cookies }) => { export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) { const denied = await requireBackups(auth, 'view');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const snapshotId = params.id; const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId); const invalidSnap = validateSnapshotId(snapshotId);
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation'; import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups'; import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
export const GET: RequestHandler = async ({ params, url, cookies }) => { export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) { const denied = await requireBackups(auth, 'view');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const snapshotId = params.id; const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId); const invalidSnap = validateSnapshotId(snapshotId);
@@ -48,6 +49,26 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({ error: 'Invalid path' }, { status: 400 }); return json({ error: 'Invalid path' }, { status: 400 });
} }
// metadata.json carries secrets (stack.secrets ciphertext + container Config.Env
// plaintext). It must ONLY leave the process through the redacting path — never as a
// raw file/byte/archive dump, which would bypass redaction. Serve a redacted inline
// preview; refuse raw downloads and reject a /metadata archive dump that would embed it.
const isMetadataFile = path === '/metadata/metadata.json';
if (isMetadataFile || (isDir && download && (path === '/metadata' || path === '/metadata/'))) {
if (download) {
return json({ error: 'metadata.json cannot be downloaded raw; use the snapshot metadata endpoint (secrets are redacted there)' }, { status: 403 });
}
try {
const raw = await dumpSnapshotFile(destinationId, snapshotId, '/metadata/metadata.json');
const layout = parseSnapshotLayout(raw);
if (!layout) return json({ error: 'metadata unreadable' }, { status: 404 });
return json({ content: JSON.stringify(redactSnapshotLayout(layout), null, 2) });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
}
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars) // Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_'); const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation'; import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { getSnapshotMetadata } from '$lib/server/backups'; import { getSnapshotMetadata } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
export const GET: RequestHandler = async ({ params, url, cookies }) => { export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) { const denied = await requireBackups(auth, 'view');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const snapshotId = params.id; const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId); const invalidSnap = validateSnapshotId(snapshotId);
@@ -28,15 +29,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try { try {
const metadata = await getSnapshotMetadata(destinationId, snapshotId); const metadata = await getSnapshotMetadata(destinationId, snapshotId);
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 }); if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
// Never expose the stored secret VALUES (ciphertext) to the client — replace the // Single redaction point: strips stack.secrets values AND container inspect
// `secrets` array with `secretKeys` (names only) so the UI can list them. // Config.Env/Labels (plaintext secrets) before anything reaches the client.
const { secrets, ...safe } = metadata as Record<string, unknown>; return json(redactSnapshotLayout(metadata));
if (Array.isArray(secrets)) {
(safe as Record<string, unknown>).secretKeys = secrets
.map((s: any) => s?.key)
.filter((k: unknown): k is string => typeof k === 'string');
}
return json(safe);
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error); const msg = error instanceof Error ? error.message : String(error);
return json({ error: msg }, { status: 500 }); return json({ error: msg }, { status: 500 });
@@ -1,15 +1,15 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { diffSnapshots } from '$lib/server/backups'; import { diffSnapshots } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { validateSnapshotId } from '$lib/server/docker-validation'; import { validateSnapshotId } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ url, cookies }) => { export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) { const denied = await requireBackups(auth, 'view');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const destId = url.searchParams.get('destinationId'); const destId = url.searchParams.get('destinationId');
const snapA = url.searchParams.get('snapshotA'); const snapA = url.searchParams.get('snapshotA');
+35 -16
View File
@@ -106,6 +106,21 @@
const isVolumeMode = $derived(mode === 'volume'); const isVolumeMode = $derived(mode === 'volume');
const isSnapshotMode = $derived(mode === 'snapshot'); const isSnapshotMode = $derived(mode === 'snapshot');
// In a snapshot, the stack dir rides a reserved volume key; show it by what it holds,
// not the raw internal key. Navigation still uses the real name.
function displayEntryName(name: string): string {
if (isSnapshotMode && name === '__dockhand_stackdir__') return 'Stack files (compose, config)';
return name;
}
// A raw download of the snapshot's metadata.json is refused server-side (403) — it carries
// secrets and must only leave through the redacting metadata endpoint. So don't offer a
// download button that would always fail; the file is still previewable (redacted).
function canDownloadEntry(entry: FileEntry): boolean {
if (!isSnapshotMode) return true;
return !(entry.name === 'metadata.json' && (currentPath === '/metadata' || currentPath === '/metadata/'));
}
// Effective canEdit: snapshots are always read-only; volumes only if not in use // Effective canEdit: snapshots are always read-only; volumes only if not in use
const effectiveCanEdit = $derived( const effectiveCanEdit = $derived(
isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit
@@ -934,7 +949,7 @@
title={segment} title={segment}
onclick={() => navigateTo('/' + pathSegments().slice(0, i + 1).join('/'))} onclick={() => navigateTo('/' + pathSegments().slice(0, i + 1).join('/'))}
> >
{segment} {displayEntryName(segment)}
</button> </button>
{/each} {/each}
</div> </div>
@@ -1076,14 +1091,16 @@
onclick={() => handleEntryClick(entry)} onclick={() => handleEntryClick(entry)}
> >
<Icon <Icon
class="w-3.5 h-3.5 shrink-0 {entry.type === 'directory' class="w-3.5 h-3.5 shrink-0 {isSnapshotMode && entry.name === '__dockhand_stackdir__'
? 'text-blue-500' ? 'text-amber-500'
: entry.type === 'symlink' : entry.type === 'directory'
? 'text-purple-500' ? 'text-blue-500'
: 'text-muted-foreground'}" : entry.type === 'symlink'
? 'text-purple-500'
: 'text-muted-foreground'}"
/> />
<span class="truncate" title={entry.name}> <span class="truncate" title={entry.name}>
{entry.name} {displayEntryName(entry.name)}
{#if entry.type === 'symlink' && entry.linkTarget} {#if entry.type === 'symlink' && entry.linkTarget}
<span class="text-muted-foreground ml-1"> <span class="text-muted-foreground ml-1">
→ {entry.linkTarget} → {entry.linkTarget}
@@ -1188,15 +1205,17 @@
{/snippet} {/snippet}
</ConfirmPopover> </ConfirmPopover>
{/if} {/if}
<Button {#if canDownloadEntry(entry)}
variant="ghost" <Button
size="icon" variant="ghost"
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity" size="icon"
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }} class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
title="Download" onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
> title="Download"
<Download class="w-3 h-3" /> >
</Button> <Download class="w-3 h-3" />
</Button>
{/if}
</div> </div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
+26 -40
View File
@@ -12,6 +12,7 @@
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings'; import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
import { environments } from '$lib/stores/environment'; import { environments } from '$lib/stores/environment';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import SnapshotHeader from '$lib/components/backup/SnapshotHeader.svelte';
import FileBrowserPanel from './FileBrowserPanel.svelte'; import FileBrowserPanel from './FileBrowserPanel.svelte';
interface Props { interface Props {
@@ -105,33 +106,21 @@
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden"> <Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden">
<Dialog.Header> <Dialog.Header>
<Dialog.Title class="flex items-center gap-2 flex-wrap"> <Dialog.Title>
<FolderOpen class="w-5 h-5" /> <SnapshotHeader
Browse snapshot icon={FolderOpen}
{#if snapshotEnvName} verb="Browse snapshot"
<span class="text-muted-foreground font-normal">from</span> name={displayName}
<EnvironmentIcon icon={snapshotEnvIcon} envId={snapshotEnvId} class="w-4 h-4 text-amber-600 dark:text-amber-400" /> nameType={(targetType || metadata?.type) === 'stack' ? 'stack' : 'container'}
<span class="font-normal text-amber-600 dark:text-amber-400">{snapshotEnvName}</span> {destinationName}
{/if} destinationRepository={destinationRepo}
{#if displayName} sourceEnv={{ id: snapshotEnvId, icon: snapshotEnvIcon }}
<span class="text-muted-foreground font-normal">—</span> sourceEnvName={snapshotEnvName}
{#if (targetType || metadata?.type) === 'stack'} snapshotId={snapshotId ? String(snapshotId) : undefined}
<Layers class="w-4 h-4 text-purple-500" /> {snapshotTime}
{:else} />
<Box class="w-4 h-4 text-blue-500" />
{/if}
<span class="font-normal">{displayName}</span>
{/if}
{#if destinationName}
<span class="text-muted-foreground font-normal">on</span>
<svelte:component this={getRepoTypeIcon(destinationRepo)} class="w-4 h-4" />
<span class="font-normal">{destinationName}</span>
{/if}
</Dialog.Title> </Dialog.Title>
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground"> <Dialog.Description class="sr-only">Browse files in snapshot {snapshotId ? String(snapshotId).slice(0, 8) : ''}.</Dialog.Description>
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
{#if snapshotTime}{formatDateTime(snapshotTime)} <span class="opacity-60">({formatRelativeTime(snapshotTime)})</span>{/if}
</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<!-- Tabs --> <!-- Tabs -->
@@ -192,12 +181,12 @@
<dt class="text-muted-foreground">Volumes</dt> <dt class="text-muted-foreground">Volumes</dt>
<dd>{metadata.volumes?.length ?? 0}</dd> <dd>{metadata.volumes?.length ?? 0}</dd>
{#if metadata.type === 'stack'} {#if metadata.type === 'stack'}
{#if metadata.composeFileName} {#if metadata.stack?.composeFileName}
<dt class="text-muted-foreground">Compose file</dt> <dt class="text-muted-foreground">Compose file</dt>
<dd class="font-mono">{metadata.composeFileName}</dd> <dd class="font-mono">{metadata.stack.composeFileName}</dd>
{/if} {/if}
<dt class="text-muted-foreground">Stack files</dt> <dt class="text-muted-foreground">Stack files</dt>
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd> <dd>{metadata.hasStackFiles ? 'captured' : 'not captured'}</dd>
{/if} {/if}
</dl> </dl>
</section> </section>
@@ -229,34 +218,31 @@
<!-- Stack files (captured under stackfiles/). The list is recorded in <!-- Stack files (captured under stackfiles/). The list is recorded in
metadata.json at backup time so we can enumerate the exact files metadata.json at backup time so we can enumerate the exact files
without a browse roundtrip. --> without a browse roundtrip. -->
{#if metadata.type === 'stack' && metadata.stackFilesList?.length} {#if metadata.type === 'stack' && metadata.stack?.fileList?.length}
<section class="space-y-2 border-t pt-4"> <section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})</h4> <h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stack.fileList.length})</h4>
<div class="space-y-1"> <div class="space-y-1">
{#each metadata.stackFilesList as file} {#each metadata.stack.fileList as file}
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5"> <div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.composeFileName}>{file.path}</span> <span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.stack.composeFileName}>{file.path}</span>
{#if file.path === metadata.composeFileName} {#if file.path === metadata.stack.composeFileName}
<Badge variant="outline" class="text-[10px] shrink-0">compose</Badge> <Badge variant="outline" class="text-[10px] shrink-0">compose</Badge>
{/if} {/if}
<span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span> <span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span>
</div> </div>
{/each} {/each}
</div> </div>
{#if metadata.stackFilesListTruncated}
<p class="text-xs text-muted-foreground">Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.</p>
{/if}
</section> </section>
{/if} {/if}
<!-- Secrets carried in the snapshot (KEY NAMES only — the values are <!-- Secrets carried in the snapshot (KEY NAMES only — the values are
stored encrypted and never exposed here). Restored to the target stored encrypted and never exposed here). Restored to the target
DB on restore unless the user opts out. --> DB on restore unless the user opts out. -->
{#if metadata.type === 'stack' && metadata.secretKeys?.length} {#if metadata.type === 'stack' && metadata.stack?.secretKeys?.length}
<section class="space-y-2 border-t pt-4"> <section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.secretKeys.length})</h4> <h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.stack.secretKeys.length})</h4>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each metadata.secretKeys as key} {#each metadata.stack.secretKeys as key}
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code> <code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
{/each} {/each}
</div> </div>