diff --git a/src/lib/server/backups/browse-core.ts b/src/lib/server/backups/browse-core.ts index b871e8a..fd7c2b5 100644 --- a/src/lib/server/backups/browse-core.ts +++ b/src/lib/server/backups/browse-core.ts @@ -3,6 +3,24 @@ * 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, * always leading-slash, no trailing slash (root stays "/"). */ 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 * `/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 entries: any[] = []; + const entries: ResticLsNode[] = []; for (const line of stdout.split('\n')) { const t = line.trim(); if (!t) continue; - let e: any; + let e: ResticLsNode; 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 entries.push(e); } diff --git a/src/lib/server/backups/discovery-core.ts b/src/lib/server/backups/discovery-core.ts index 015dd95..c9bf231 100644 --- a/src/lib/server/backups/discovery-core.ts +++ b/src/lib/server/backups/discovery-core.ts @@ -14,6 +14,22 @@ */ 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/` 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 `:/volumes/:` 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). */ export interface Mount { @@ -54,19 +70,6 @@ export interface DiscoveryResult { 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 = 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 * key on their volume name (stable, restore knows it); binds key on a @@ -77,9 +80,8 @@ export function safeKey(base: string, taken: Set = new Set()): string { export function discoverVolumesFromMounts(containers: Array<{ name: string; mounts: Mount[] }>): DiscoveryResult { const volumes: DiscoveredVolume[] = []; const skipped: SkippedMount[] = []; - const seenVolumeNames = new Set(); - const seenBindPairs = new Set(); - const takenKeys = new Set(); + const seen = new Set(); // a volume shared across containers is captured once + const takenKeys = new Set(); // no two captures share a /volumes/ for (const container of containers) { 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 (seenVolumeNames.has(mount.Name)) continue; - seenVolumeNames.add(mount.Name); - takenKeys.add(mount.Name); + const dedup = volumeDedupKey({ type: 'volume', source: mount.Name, destination: mount.Destination ?? '', name: mount.Name }); + if (seen.has(dedup)) continue; + 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({ - key: mount.Name, - bind: `${mount.Name}:/volumes/${mount.Name}:ro`, + key, + bind: volumeBind(mount.Name, key, 'ro'), name: mount.Name, type: 'volume', source: mount.Name, @@ -112,13 +117,15 @@ export function discoverVolumesFromMounts(containers: Array<{ name: string; moun }); continue; } - const pairKey = `${mount.Source}\n${mount.Destination}`; - if (seenBindPairs.has(pairKey)) continue; - seenBindPairs.add(pairKey); - const key = safeKey(mount.Destination, takenKeys); + // Two binds to the same destination from DIFFERENT sources are different volumes + // and both survive (#1373); only an identical (source,destination) is deduped. + const dedup = volumeDedupKey({ type: 'bind', source: mount.Source, destination: mount.Destination }); + if (seen.has(dedup)) continue; + seen.add(dedup); + const key = volumeStorageKey({ type: 'bind', source: mount.Source, destination: mount.Destination }, takenKeys); volumes.push({ key, - bind: `${mount.Source}:/volumes/${key}:ro`, + bind: volumeBind(mount.Source, key, 'ro'), name: mount.Destination, type: 'bind', source: mount.Source, diff --git a/src/lib/server/backups/error-message.ts b/src/lib/server/backups/error-message.ts new file mode 100644 index 0000000..b5c59fd --- /dev/null +++ b/src/lib/server/backups/error-message.ts @@ -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; +} diff --git a/src/lib/server/backups/helpers.ts b/src/lib/server/backups/helpers.ts index 72a4fe4..04fe7b3 100644 --- a/src/lib/server/backups/helpers.ts +++ b/src/lib/server/backups/helpers.ts @@ -7,7 +7,7 @@ import { isValidCron } from '../scheduler/cron-utils'; import { privateIpReason, dangerousHostReason, isSafeWebhookUrl, isSafeNotificationUrl } from '../url-safety'; -import { BackupError } from './models'; +import { BackupError, isLocalRepo } from './models'; export { privateIpReason }; // Restic exits 10 when the repo isn't initialized. @@ -30,11 +30,98 @@ export function parseOptionsJson(options: string | null | undefined): Record): 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 * object, logging ONCE so a corrupt policies field is diagnosable rather than - * silently disabling autoUnlock/prune/check/verify. This is the single source of - * truth for the previously-duplicated inline IIFEs. + * silently disabling autoUnlock/prune/check/verify. */ export function parsePoliciesJson(policies: string | null | undefined): Record { 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 { if (!repository || typeof repository !== 'string') return false; 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)); } @@ -114,8 +201,7 @@ export function sanitizeWebhookUrlForLog(raw: string): string { /** * 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 - * legacy engine, keeping its three hardening layers: + * fire-and-forget: a webhook never changes the backup outcome. Three hardening layers: * - SSRF literal-host guard (isSafeWebhookUrl): block loopback/private/metadata. * - DNS-rebinding guard: resolve the hostname and re-check EVERY resolved IP * 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 * colour escapes that restic sometimes emits when stderr is a TTY (the helper * container's stderr looks like a TTY to restic). */ -export function cleanErrorMsg(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 (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; -} +// cleanErrorMsg lives in error-message.ts; re-exported so importers of helpers.ts keep working. +export { cleanErrorMsg } from './error-message'; /** - * Inspect `restic forget --dry-run --json` output and decide whether running - * the real `forget --prune` would delete every snapshot in the targeted group. - * - * 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. + * The delete-everything guard for `restic forget --dry-run --json`. Re-exported from retention.ts + * (`checkWouldWipe`) so this data-loss guard lives in exactly one place - a second copy could drift. */ -export function wouldDeleteAllSnapshots(stdout: string): { - 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 }; -} +export { checkWouldWipe as wouldDeleteAllSnapshots } from './retention'; // ----------------------------------------------------------------------------- // restic subprocess hardening @@ -356,10 +381,8 @@ export function filterCloudEnvVars(envVars: Record): Record): string[] { if (!flags || !flags.trim()) return []; const tokens = flags.trim().split(/\s+/); const out: string[] = []; let expectValue = false; for (const tok of tokens) { if (expectValue) { - // This token is the value of the previous value-taking flag. - out.push(tok); + out.push(tok); // value of the previous space-separated value-taking flag expectValue = false; continue; } 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}`); } 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 (expectValue) throw new Error('Restic flag is missing its value'); 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 — * 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 - * that gains a schedule. - * - * The motivating flow: a "run once" backup persists a MANUAL, paused config - * (schedule=null, enabled=false) — correct on its own. But when the user later edits - * 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). + * Decide the `enabled` flag when a backup config is updated. A "run once" backup persists a + * 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 + * scheduled is left alone, so a deliberately-paused scheduled backup stays paused across edits. + * Applies to container and stack backups alike (both go through PUT). */ export function resolveEnabledOnScheduleChange(input: { 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. */ export function validateFlags(flags: unknown): string | null { if (flags === undefined || flags === null) return null; - try { sanitizeResticFlags(flags as string); return null; } - catch (e) { return e instanceof Error ? e.message : 'Invalid restic flags'; } + // The route may send either the split shape ({ backup, restore }) or a legacy bare + // 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 }); } /** diff --git a/src/lib/server/backups/locks.ts b/src/lib/server/backups/locks.ts index 5f95a5c..941c922 100644 --- a/src/lib/server/backups/locks.ts +++ b/src/lib/server/backups/locks.ts @@ -31,9 +31,18 @@ * different keys, fail to collide, and run concurrently — a torn snapshot * 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 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('|')}`; } diff --git a/src/lib/server/backups/models.ts b/src/lib/server/backups/models.ts index dbf5fbd..6102850 100644 --- a/src/lib/server/backups/models.ts +++ b/src/lib/server/backups/models.ts @@ -29,8 +29,7 @@ export type OperationStatus = | 'success' | 'warning' | 'error' - | 'cancelled' - | 'stale'; + | 'cancelled'; /** * Closed error taxonomy. The UI shows a human message; automation branches on @@ -226,6 +225,7 @@ export function parseSnapshots(stdout: string): Snapshot[] { return out; } + // ============================================================================= // 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 * decoration for humans browsing with the restic CLI. * - * Why stable ids: the old code tagged `dockhand:config=--` - * built from a NORMALIZED env name, which collides when two env names normalize - * to the same string — causing retention to cross-prune and env-access to - * resolve to the wrong environment. Ids never collide. + * Why stable ids, not names: a normalized env name collides when two env names normalize to the + * same string, which would cross-prune retention and resolve env-access to the wrong environment. + * Ids never collide. */ export type BackupTargetType = 'container' | 'stack'; @@ -308,3 +307,12 @@ export const TIMEOUTS = { } as const; 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('/'); +} diff --git a/src/lib/server/backups/operations.ts b/src/lib/server/backups/operations.ts index 137dbc6..565c6d4 100644 --- a/src/lib/server/backups/operations.ts +++ b/src/lib/server/backups/operations.ts @@ -7,18 +7,17 @@ * A record opens as `running`, streams throttled progress + log lines, and * closes exactly once with a terminal status DERIVED IN ONE PLACE (see * ./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. * - * The pure decision logic (terminal-status derivation, progress throttle, scrub - * rule) lives in ./operations-core and is unit-tested there; this module is the - * thin DB-bound wrapper. + * The pure decision logic (terminal-status derivation, progress throttle) lives + * in ./operations-core and is unit-tested there; this module is the thin + * DB-bound wrapper. */ import { createScheduleExecution, updateScheduleExecution, appendScheduleExecutionLog, - failStaleRunningExecutions, } from '../db'; import type { OperationKind } from './models'; import { @@ -91,7 +90,7 @@ export class Operation { * metadata/verifying/pruning, the volume list, errors/warnings) and is ALWAYS * persisted — otherwise those lines drop out of the execution log * 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 { 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 { 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 { - return failStaleRunningExecutions(); -} diff --git a/src/lib/server/backups/put-archive-transport.ts b/src/lib/server/backups/put-archive-transport.ts new file mode 100644 index 0000000..624e5bd --- /dev/null +++ b/src/lib/server/backups/put-archive-transport.ts @@ -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 | 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, ''); +} diff --git a/src/lib/server/backups/request-timeout.ts b/src/lib/server/backups/request-timeout.ts new file mode 100644 index 0000000..705cdf1 --- /dev/null +++ b/src/lib/server/backups/request-timeout.ts @@ -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; +} diff --git a/src/lib/server/backups/restore-core.ts b/src/lib/server/backups/restore-core.ts index 7474d7f..ce58547 100644 --- a/src/lib/server/backups/restore-core.ts +++ b/src/lib/server/backups/restore-core.ts @@ -16,17 +16,16 @@ * true source for a bind key, we FAIL LOUD rather than fall back to the slug. */ 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 interface StoredVolume { - key: string; - name: string; - source: string; - type: 'volume' | 'bind'; -} +export { volumeInclude, volumeBind }; -/** 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 { Type?: string; Name?: string; @@ -34,10 +33,14 @@ interface StoredMount { 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 { volumes?: unknown; - container?: { Mounts?: StoredMount[] } | null; + container?: unknown; } /** A `:/volumes/:rw` bind + its `/volumes/` include. `type` and @@ -52,7 +55,7 @@ export interface VolumeBind { /** Build the rw bind for a resolved source + key. */ 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. */ 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; for (const m of mounts) { 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; } } - const mounts = metadata?.container?.Mounts; + const c = metadata?.container as { Mounts?: StoredMount[] } | null | undefined; + const mounts = c?.Mounts; if (Array.isArray(mounts)) { for (const m of mounts) { if (m?.Type === 'bind' && m.Destination && safeKey(m.Destination) === key) return true; diff --git a/src/lib/server/backups/retention.ts b/src/lib/server/backups/retention.ts index 04ef4f4..a6add8f 100644 --- a/src/lib/server/backups/retention.ts +++ b/src/lib/server/backups/retention.ts @@ -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 * safety properties matter and both live here as pure functions: @@ -14,7 +14,7 @@ * that keeps nothing is always a mistake, not an intent. * * 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. */ @@ -38,10 +38,10 @@ function positiveInt(v: unknown): number | undefined { /** * Parse a stored retention JSON blob into a RetentionPolicy. The stored shape is - * `{ keepLast?, keepDaily?, keepWeekly?, keepMonthly?, keepYearly? }` (legacy - * flat form). Absent / all-zero / unparseable → `none` (the safe default), so a - * broken config never prunes. `keepLast` takes precedence over time buckets if - * both are somehow present (the UI only sets one). + * `{ keepLast?, keepDaily?, keepWeekly?, keepMonthly?, keepYearly? }`. Absent / + * all-zero / unparseable resolves to `none` (the safe default), so a broken config + * never prunes. `keepLast` takes precedence over time buckets if both are somehow + * present (the UI only sets one). */ export function parseRetention(stored: string | null | undefined | Record): RetentionPolicy { let obj: Record; @@ -77,7 +77,13 @@ export function retentionActive(policy: RetentionPolicy): boolean { * 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. * `--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=,dockhand:configid=` */ @@ -91,18 +97,17 @@ export function buildForgetArgs( const args = ['forget']; if (opts.json) args.push('--json'); 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 // 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 // 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 - // the repo. In both cases a LONG wait is wrong: for (a) we'd block ~10min (5m × - // dry-run + 5m × 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 - // 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 + // the repo. In both cases a LONG wait is wrong: for (a) we'd block ~10min (5m x + // 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 (forget + // is non-fatal - the backup already succeeded - and it retries next schedule). + // 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. const retryLock = process.env.RESTIC_FORGET_RETRY_LOCK || '1m'; args.push('--retry-lock', retryLock, '--group-by', '', '--tag', configTagFilter); diff --git a/src/lib/server/backups/security.ts b/src/lib/server/backups/security.ts index bf31e9f..1c9b1aa 100644 --- a/src/lib/server/backups/security.ts +++ b/src/lib/server/backups/security.ts @@ -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, - * credential handling, and restore targets safe (env/flag/cloud allowlists, - * SSRF guards, at-rest encryption, path containment). We RE-EXPORT them from - * their existing homes rather than re-deriving them: re-implementing a security - * allowlist from scratch is exactly where a rewrite reintroduces the bug the - * allowlist exists to prevent. One import site for the whole domain, so a - * reviewer sees the security surface in one place. + * Hardened, well-tested primitives that keep restic invocation, credential + * handling, and restore targets safe (env/flag/cloud allowlists, SSRF guards, + * at-rest encryption, path containment). RE-EXPORTED from their existing homes, + * not re-derived: 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 * the rewrite needs (restore-target containment + a snapshot-path allowlist). @@ -25,8 +23,10 @@ export { filterCloudEnvVars, /** Split + validate a destination's extra restic flags against an allowlist; * 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, + sanitizeRestoreFlags, /** 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. */ 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. */ const SAFE_VOLUME_INCLUDE = /^\/volumes\/[A-Za-z0-9._-]+$/; -// A stack new-location restore also extracts the stored compose files under -// /metadata/stacks/; 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 { // A config-only (metadata-only) snapshot has no volumes; its new-location // restore extracts the whole stored /metadata dir. This is a fixed literal - // (never attacker-influenced), so allow it explicitly. - return inc === '/metadata' || SAFE_VOLUME_INCLUDE.test(inc) || SAFE_STACK_INCLUDE.test(inc); + // (never attacker-influenced), so allow it explicitly. The stack dir now rides + // /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. */ @@ -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 * when joined into a path. Empty or containing a path separator / `..` → invalid. diff --git a/src/lib/server/backups/snapshot-layout.ts b/src/lib/server/backups/snapshot-layout.ts new file mode 100644 index 0000000..e9c5255 --- /dev/null +++ b/src/lib/server/backups/snapshot-layout.ts @@ -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/ 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 & { + container?: unknown; + hasStackFiles: boolean; + stack?: (Omit & { 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 } }; +} diff --git a/src/lib/server/backups/stackdir-plan.ts b/src/lib/server/backups/stackdir-plan.ts new file mode 100644 index 0000000..ad0cc28 --- /dev/null +++ b/src/lib/server/backups/stackdir-plan.ts @@ -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 `) 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 = 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 `/` 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 + * `\t\t` 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 `\t\t` 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 (/) 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 ` 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)' }; +} diff --git a/src/lib/server/backups/swap.ts b/src/lib/server/backups/swap.ts index 2dadb32..c7ab924 100644 --- a/src/lib/server/backups/swap.ts +++ b/src/lib/server/backups/swap.ts @@ -1,44 +1,35 @@ /** * 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 - * unit-tested by running the generated shell against a real temp filesystem. - * - * THE PROBLEM. An in-place restore must replace the live contents of a volume - * with a snapshot's contents. The naive "delete the live data, then copy the - * 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: + * An in-place restore replaces a live volume's contents with a snapshot's. Deleting + * 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, + * then swap by moving directory entries, never deleting the original until the + * replacement is committed. Protocol per volume: * * phase 0 recover any interrupted PRIOR swap (see recovery script) — never - * blindly delete `.dockhand-restore-old`, it may be the only copy of - * the original data. - * phase 1 restic restores into `/.dockhand-restore-new` — a staging dir - * ON THE LIVE VOLUME'S OWN FILESYSTEM. This is the failure-prone step; - * if it fails the live data is still completely untouched. + * blindly delete `.dockhand-restore-old`, it may be the only copy. + * phase 1 restic restores into `/.dockhand-restore-new` - a staging dir ON + * THE LIVE VOLUME'S OWN FILESYSTEM. Failure-prone; if it fails, live data + * is untouched. * 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 * live root (same-filesystem renames). * 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 - * is fully in place. + * at which the original is deleted, and only after the new data is in place. * - * A same-filesystem `mv` of a directory entry is atomic. The move loops operate - * on TOP-LEVEL entries (not a recursive per-file copy), so the window is a - * handful of atomic renames rather than a long byte copy. + * A same-filesystem `mv` of a directory entry is atomic; the loops move TOP-LEVEL + * entries (not a recursive per-file copy), so the window is a handful of renames. * - * THE CRASH CASE (the one thing fail-fast cannot cover). An external SIGKILL - * (restore timeout, daemon restart, reboot, OOM) can land BETWEEN the marker-2 - * and marker-3 move loops, leaving the live root partly emptied with the - * originals in `.dockhand-restore-old`. The phase marker file makes that state - * detectable, and the recovery script rolls it back to the original content. - * This recovery runs at the start of every restore AND on startup for a restore - * whose process died — so the half-swapped state is always reconciled before - * anything else touches the volume. + * Crash case fail-fast cannot cover: an external SIGKILL (timeout, daemon restart, + * reboot, OOM) can land BETWEEN the marker-2 and marker-3 loops, leaving the live + * root partly emptied with originals in `.dockhand-restore-old`. The phase marker + * makes that state detectable; the recovery script rolls it back. Recovery runs at + * the start of every restore AND on startup for a dead-process restore, so a + * half-swapped state is always reconciled before anything else touches the volume. */ /** 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 oldQ = sq(`${live}/${SWAP_OLD}`); 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 [ `if [ -f ${phaseQ} ]; then`, + ` ${say(`Recovering ${live}: rolling back an interrupted previous restore`)};`, ` if [ "$(cat ${phaseQ})" = "3" ]; then ${removeEntries(liveQ)}; fi;`, ` if [ -d ${oldQ} ]; then ${moveEntries(oldQ, liveQ)}; fi;`, ` rm -rf ${newQ} ${oldQ}; rm -f ${phaseQ};`, diff --git a/src/lib/server/backups/validate.ts b/src/lib/server/backups/validate.ts index 648904b..e8f2956 100644 --- a/src/lib/server/backups/validate.ts +++ b/src/lib/server/backups/validate.ts @@ -9,7 +9,7 @@ * what's wrong with the request/config itself. */ import { isValidCron } from '../scheduler/cron-utils'; -import { isValidSnapshotId, isSafePathSegment } from './security'; +import { isValidSnapshotId, isSafePathSegment, unsafeRestoreTargetReason } from './security'; import { parseRetention } from './retention'; /** 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())) { 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) { const mappedVols = new Set((input.volumeDestinations ?? []).map((d) => d.volume)); 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 (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 "..")` }); + } 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') { if (typeof d.target !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(d.target)) { diff --git a/src/lib/server/scheduler/index.ts b/src/lib/server/scheduler/index.ts index 49b1d9b..7564e40 100644 --- a/src/lib/server/scheduler/index.ts +++ b/src/lib/server/scheduler/index.ts @@ -164,18 +164,6 @@ export async function startScheduler(): Promise { 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 diff --git a/src/lib/server/stream-pump.ts b/src/lib/server/stream-pump.ts new file mode 100644 index 0000000..88980ea --- /dev/null +++ b/src/lib/server/stream-pump.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/lib/utils/backup-test-summary.ts b/src/lib/utils/backup-test-summary.ts new file mode 100644 index 0000000..87a57a8 --- /dev/null +++ b/src/lib/utils/backup-test-summary.ts @@ -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' }; +} diff --git a/src/routes/api/backup/configs/+server.ts b/src/routes/api/backup/configs/+server.ts index 3a973e7..7f42ce5 100644 --- a/src/routes/api/backup/configs/+server.ts +++ b/src/routes/api/backup/configs/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditBackup } from '$lib/server/audit'; import { getBackupConfigs, @@ -12,9 +13,8 @@ import { validateRetention, retentionToStore } from '$lib/server/backups/helpers export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'view')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; const type = url.searchParams.get('type') ?? 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) => { const { request, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const body = await request.json(); diff --git a/src/routes/api/backup/destinations/[id]/init/+server.ts b/src/routes/api/backup/destinations/[id]/init/+server.ts index 4b7afe1..fface9d 100644 --- a/src/routes/api/backup/destinations/[id]/init/+server.ts +++ b/src/routes/api/backup/destinations/[id]/init/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditBackupDestination } from '$lib/server/audit'; import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db'; import { initRepository } from '$lib/server/backups'; @@ -8,9 +9,8 @@ import { initRepository } from '$lib/server/backups'; export const POST: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const id = parseInt(params.id); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); diff --git a/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts b/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts index 7ebddc0..fea786f 100644 --- a/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts +++ b/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditBackupDestination } from '$lib/server/audit'; import { getBackupDestination } from '$lib/server/db'; import { rotateDestinationPassword } from '$lib/server/backups'; @@ -20,9 +21,8 @@ import { rotateDestinationPassword } from '$lib/server/backups'; export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const id = parseInt(params.id); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); diff --git a/src/routes/api/backup/destinations/[id]/test/+server.ts b/src/routes/api/backup/destinations/[id]/test/+server.ts index fde377f..c14f860 100644 --- a/src/routes/api/backup/destinations/[id]/test/+server.ts +++ b/src/routes/api/backup/destinations/[id]/test/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { getBackupDestination, updateBackupDestinationTestStatus @@ -9,9 +10,8 @@ import { testRepository } from '$lib/server/backups'; export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const id = parseInt(params.id); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); diff --git a/src/routes/api/backup/destinations/[id]/verify/+server.ts b/src/routes/api/backup/destinations/[id]/verify/+server.ts index 3b63c86..f1b156f 100644 --- a/src/routes/api/backup/destinations/[id]/verify/+server.ts +++ b/src/routes/api/backup/destinations/[id]/verify/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditBackupDestination } from '$lib/server/audit'; import { getBackupDestination } from '$lib/server/db'; import { verifyBackup } from '$lib/server/backups'; @@ -9,9 +10,8 @@ import { createJobResponse } from '$lib/server/sse'; export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const destinationId = parseInt(params.id); if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 }); diff --git a/src/routes/api/backup/restore/stop/+server.ts b/src/routes/api/backup/restore/stop/+server.ts index 805f0ca..5e07234 100644 --- a/src/routes/api/backup/restore/stop/+server.ts +++ b/src/routes/api/backup/restore/stop/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditRestore } from '$lib/server/audit'; import { cancelRestore } from '$lib/server/backups'; import { isValidSnapshotId } from '$lib/server/backups/helpers'; @@ -14,9 +15,8 @@ import { isValidSnapshotId } from '$lib/server/backups/helpers'; export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const body = await request.json().catch(() => ({})); const snapshotId: string | undefined = body.snapshotId; diff --git a/src/routes/api/backup/snapshots/[id]/+server.ts b/src/routes/api/backup/snapshots/[id]/+server.ts index 6dfe277..e99dec0 100644 --- a/src/routes/api/backup/snapshots/[id]/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import { validateSnapshotId } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditBackup } from '$lib/server/audit'; import { getBackupDestination } from '$lib/server/db'; import { forgetSnapshot } from '$lib/server/backups'; @@ -10,9 +11,8 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const rbacDenied = await requireBackups(auth, 'manage'); + if (rbacDenied) return rbacDenied; const snapshotId = params.id; const invalidSnap = validateSnapshotId(snapshotId); diff --git a/src/routes/api/backup/snapshots/[id]/browse/+server.ts b/src/routes/api/backup/snapshots/[id]/browse/+server.ts index e0dc3a8..6be4131 100644 --- a/src/routes/api/backup/snapshots/[id]/browse/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/browse/+server.ts @@ -2,14 +2,14 @@ import { json } from '@sveltejs/kit'; import { validateSnapshotId } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { browseSnapshot } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'view')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; const snapshotId = params.id; const invalidSnap = validateSnapshotId(snapshotId); diff --git a/src/routes/api/backup/snapshots/[id]/dump/+server.ts b/src/routes/api/backup/snapshots/[id]/dump/+server.ts index efb0e2c..7980b49 100644 --- a/src/routes/api/backup/snapshots/[id]/dump/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/dump/+server.ts @@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit'; import { validateSnapshotId } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups'; 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 }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'view')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; const snapshotId = params.id; const invalidSnap = validateSnapshotId(snapshotId); @@ -48,6 +49,26 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { 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) const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_'); diff --git a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts index 3de77a0..3b0361d 100644 --- a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts @@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit'; import { validateSnapshotId } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { getSnapshotMetadata } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; +import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout'; export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'view')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; const snapshotId = params.id; const invalidSnap = validateSnapshotId(snapshotId); @@ -28,15 +29,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const metadata = await getSnapshotMetadata(destinationId, snapshotId); if (!metadata) return json({ error: 'No metadata available' }, { status: 404 }); - // Never expose the stored secret VALUES (ciphertext) to the client — replace the - // `secrets` array with `secretKeys` (names only) so the UI can list them. - const { secrets, ...safe } = metadata as Record; - if (Array.isArray(secrets)) { - (safe as Record).secretKeys = secrets - .map((s: any) => s?.key) - .filter((k: unknown): k is string => typeof k === 'string'); - } - return json(safe); + // Single redaction point: strips stack.secrets values AND container inspect + // Config.Env/Labels (plaintext secrets) before anything reaches the client. + return json(redactSnapshotLayout(metadata)); } catch (error) { const msg = error instanceof Error ? error.message : String(error); return json({ error: msg }, { status: 500 }); diff --git a/src/routes/api/backup/snapshots/diff/+server.ts b/src/routes/api/backup/snapshots/diff/+server.ts index 838d100..e721589 100644 --- a/src/routes/api/backup/snapshots/diff/+server.ts +++ b/src/routes/api/backup/snapshots/diff/+server.ts @@ -1,15 +1,15 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { diffSnapshots } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { validateSnapshotId } from '$lib/server/docker-validation'; export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'view')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; const destId = url.searchParams.get('destinationId'); const snapA = url.searchParams.get('snapshotA'); diff --git a/src/routes/containers/FileBrowserPanel.svelte b/src/routes/containers/FileBrowserPanel.svelte index 4a6ffe8..fb52fae 100644 --- a/src/routes/containers/FileBrowserPanel.svelte +++ b/src/routes/containers/FileBrowserPanel.svelte @@ -106,6 +106,21 @@ const isVolumeMode = $derived(mode === 'volume'); 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 const effectiveCanEdit = $derived( isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit @@ -934,7 +949,7 @@ title={segment} onclick={() => navigateTo('/' + pathSegments().slice(0, i + 1).join('/'))} > - {segment} + {displayEntryName(segment)} {/each} @@ -1076,14 +1091,16 @@ onclick={() => handleEntryClick(entry)} > - {entry.name} + {displayEntryName(entry.name)} {#if entry.type === 'symlink' && entry.linkTarget} → {entry.linkTarget} @@ -1188,15 +1205,17 @@ {/snippet} {/if} - + {#if canDownloadEntry(entry)} + + {/if} diff --git a/src/routes/containers/SnapshotBrowser.svelte b/src/routes/containers/SnapshotBrowser.svelte index 073cfa8..551f799 100644 --- a/src/routes/containers/SnapshotBrowser.svelte +++ b/src/routes/containers/SnapshotBrowser.svelte @@ -12,6 +12,7 @@ import { formatDateTime, formatRelativeTime } from '$lib/stores/settings'; import { environments } from '$lib/stores/environment'; import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; + import SnapshotHeader from '$lib/components/backup/SnapshotHeader.svelte'; import FileBrowserPanel from './FileBrowserPanel.svelte'; interface Props { @@ -105,33 +106,21 @@ - - - Browse snapshot - {#if snapshotEnvName} - from - - {snapshotEnvName} - {/if} - {#if displayName} - — - {#if (targetType || metadata?.type) === 'stack'} - - {:else} - - {/if} - {displayName} - {/if} - {#if destinationName} - on - - {destinationName} - {/if} + + - - {#if snapshotId}{String(snapshotId).slice(0, 8)}{/if} - {#if snapshotTime}{formatDateTime(snapshotTime)} ({formatRelativeTime(snapshotTime)}){/if} - + Browse files in snapshot {snapshotId ? String(snapshotId).slice(0, 8) : ''}. @@ -192,12 +181,12 @@
Volumes
{metadata.volumes?.length ?? 0}
{#if metadata.type === 'stack'} - {#if metadata.composeFileName} + {#if metadata.stack?.composeFileName}
Compose file
-
{metadata.composeFileName}
+
{metadata.stack.composeFileName}
{/if}
Stack files
-
{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}
+
{metadata.hasStackFiles ? 'captured' : 'not captured'}
{/if} @@ -229,34 +218,31 @@ - {#if metadata.type === 'stack' && metadata.stackFilesList?.length} + {#if metadata.type === 'stack' && metadata.stack?.fileList?.length}
-

Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})

+

Stack files ({metadata.stack.fileList.length})

- {#each metadata.stackFilesList as file} + {#each metadata.stack.fileList as file}
- {file.path} - {#if file.path === metadata.composeFileName} + {file.path} + {#if file.path === metadata.stack.composeFileName} compose {/if} {formatBytes(file.bytes)}
{/each}
- {#if metadata.stackFilesListTruncated} -

Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.

- {/if}
{/if} - {#if metadata.type === 'stack' && metadata.secretKeys?.length} + {#if metadata.type === 'stack' && metadata.stack?.secretKeys?.length}
-

Secrets ({metadata.secretKeys.length})

+

Secrets ({metadata.stack.secretKeys.length})

- {#each metadata.secretKeys as key} + {#each metadata.stack.secretKeys as key} {key} {/each}