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

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

No files matched your search

+22 -4
View File
@@ -3,6 +3,24 @@
* snapshot browser. No docker, no DB, so it is unit-testable directly.
*/
/** 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);
}
+33 -26
View File
@@ -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/<key>` mount point a captured/restored volume rides on. ONE definition of the
* mount-point contract so backup capture (ro), restore (rw), and the preview can't drift on it. */
export function volumeInclude(key: string): string {
return `/volumes/${key}`;
}
/** The `<source>:/volumes/<key>:<mode>` helper bind string. Backup capture uses `ro`, restore `rw`. */
export function volumeBind(source: string, key: string, mode: 'ro' | 'rw'): string {
return `${source}:${volumeInclude(key)}:${mode}`;
}
/** A single mount as returned by a container inspect (the fields we use). */
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<string> = new Set()): string {
let key = base.replace(/^\//, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 100) || 'bind';
if (!taken.has(key)) { taken.add(key); return key; }
let i = 2;
while (taken.has(`${key}_${i}`)) i++;
const out = `${key}_${i}`;
taken.add(out);
return out;
}
/**
* Turn a list of containers' mounts into the volumes to back up. Named volumes
* key on their volume name (stable, restore knows it); binds key on a
@@ -77,9 +80,8 @@ export function safeKey(base: string, taken: Set<string> = new Set()): string {
export function discoverVolumesFromMounts(containers: Array<{ name: string; mounts: Mount[] }>): DiscoveryResult {
const volumes: DiscoveredVolume[] = [];
const skipped: SkippedMount[] = [];
const seenVolumeNames = new Set<string>();
const seenBindPairs = new Set<string>();
const takenKeys = new Set<string>();
const seen = new Set<string>(); // a volume shared across containers is captured once
const takenKeys = new Set<string>(); // no two captures share a /volumes/<key>
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,
+71
View File
@@ -0,0 +1,71 @@
/**
* backups/error-message.ts — turn restic / Docker error text into a readable
* sentence for the UI. Pure and dependency-free so it is unit-testable directly.
*
* The inputs are messy: ANSI colour codes, a whole JSON error object, an error
* with embedded JSON, and the worst case — a KILLED helper whose "Container
* exited with code N:" detail is followed by several restic `--json` PROGRESS
* lines glued together (no error `message` in any of them). Each case collapses
* to the shortest true, readable string.
*/
/**
* cleanErrorMsg is on the error path — it must NEVER throw and become the crash it
* was meant to describe. Coerce any non-string to its raw string, and wrap the whole
* parse in a try/catch that falls back to the raw input. We never fabricate a message:
* raw text is always better than swallowing the real error.
*/
export function cleanErrorMsg(msg: unknown): string {
const raw = typeof msg === 'string' ? msg : safeString(msg);
try {
return clean(raw);
} catch {
// Any unforeseen failure in parsing → return the raw string untouched.
return raw;
}
}
/** Coerce anything to a string without ever throwing (a hostile toString() can). */
function safeString(v: unknown): string {
try { return String(v); } catch { return ''; }
}
function clean(msg: string): string {
// Strip ANSI escape codes
const clean = msg.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
// Try parsing the whole string as JSON
try { const p = JSON.parse(clean); if (typeof p?.message === 'string' && p.message) return p.message; } catch { /* not pure JSON */ }
// Try extracting embedded JSON — replace real newlines so JSON.parse works
const jsonStart = clean.indexOf('{');
const jsonEnd = clean.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
try {
const jsonStr = clean.slice(jsonStart, jsonEnd + 1).replace(/\n/g, '\\n');
const parsed = JSON.parse(jsonStr);
// Only a STRING message is usable — a number/object would cast to "123"
// or "[object Object]", which is worse than the raw text.
if (typeof parsed?.message === 'string' && parsed.message) {
const prefix = clean.slice(0, jsonStart).trim();
const message = parsed.message.replace(/\\n/g, ' ').trim();
return prefix ? `${prefix} ${message}` : message;
}
if (parsed.message_type) {
// A killed helper appends restic's last --json PROGRESS line
// (message_type, no `message`) to the exit-code detail — no error
// text; drop it, keep just the readable prefix.
const p2 = clean.slice(0, jsonStart).trim().replace(/:\s*$/, '');
if (p2) return p2;
}
} catch {
// The whole slice didn't parse as one JSON - which is what a KILLED helper
// produces: "Container exited with code N: {progress} {progress} ...",
// several restic --json PROGRESS lines concatenated after the exit detail.
// None carry an error message; keep just the readable prefix before them.
if (/^\{.*"message_type"/.test(clean.slice(jsonStart, jsonStart + 200))) {
const p3 = clean.slice(0, jsonStart).trim().replace(/:\s*$/, '');
if (p3) return p3;
}
}
}
return clean;
}
+152 -112
View File
@@ -7,7 +7,7 @@
import { isValidCron } from '../scheduler/cron-utils';
import { 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<str
}
}
/** The job.options shape a backup run consumes (subset of what's persisted on a config). */
export interface JobOptions {
excludePatterns?: string[];
excludeCaches?: boolean;
compression?: string;
limitUpload?: number;
limitDownload?: number;
webhookSuccess?: string;
webhookFailure?: string;
excludedStackFiles?: string[];
}
/**
* Map a config's stored options (from parseOptionsJson) into the job.options a backup run uses.
* Pure so a SAVED backup (scheduled or "run now") gets EXACTLY the same options as an in-request
* run. Every persisted field must be forwarded here, or it silently vanishes on the config-driven path.
*/
export function buildJobOptions(opts: Record<string, any>): JobOptions {
return {
excludePatterns: typeof opts.excludePatterns === 'string'
? opts.excludePatterns.split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined,
excludeCaches: opts.excludeCaches,
compression: opts.compression,
limitUpload: opts.limitUpload,
limitDownload: opts.limitDownload,
webhookSuccess: typeof opts.webhookSuccess === 'string' ? opts.webhookSuccess : undefined,
webhookFailure: typeof opts.webhookFailure === 'string' ? opts.webhookFailure : undefined,
excludedStackFiles: Array.isArray(opts.excludedStackFiles) ? opts.excludedStackFiles : undefined,
};
}
/** A destination's extra restic flags, split by scope. `backup` applies to backup + repo
* ops; `restore` applies only to `restic restore`. */
export interface BackupFlags {
backup: string;
restore: string;
}
/**
* Parse the stored `flags` column into typed {backup, restore}. The column is a JSON
* object `{"backup":"...","restore":"..."}`. A LEGACY value (a bare flag string like
* `--limit-upload 5120`, or malformed JSON) is treated as BACKUP flags with an empty
* restore string. SINGLE place that string-vs-JSON is decided, so the ambiguity can't
* leak elsewhere.
*/
export function parseBackupFlags(flags: string | null | undefined): BackupFlags {
if (!flags || !flags.trim()) return { backup: '', restore: '' };
const trimmed = flags.trim();
// Only a JSON OBJECT counts as the new shape; anything else is a legacy flag string.
if (trimmed.startsWith('{')) {
try {
const o = JSON.parse(trimmed);
if (o && typeof o === 'object' && !Array.isArray(o)) {
return {
backup: typeof o.backup === 'string' ? o.backup : '',
restore: typeof o.restore === 'string' ? o.restore : '',
};
}
} catch { /* fall through to legacy */ }
}
return { backup: trimmed, restore: '' }; // legacy bare string = backup flags
}
/** Serialize typed {backup, restore} back into the JSON stored in the `flags` column.
* Returns null when both are empty (keeps the column NULL rather than storing `{}`). */
export function serializeBackupFlags(f: BackupFlags): string | null {
const backup = (f.backup ?? '').trim();
const restore = (f.restore ?? '').trim();
if (!backup && !restore) return null;
return JSON.stringify({ backup, restore });
}
/**
* Parse a stored `selected_volumes` JSON blob into a `string[]` or null. Guards the SHAPE,
* not just the JSON: a corrupt/legacy row holding a string (or other non-array) must NOT
* flow into `new Set(...)` as an iterable-of-chars, which would match no volume key and
* silently filter EVERY volume out (a metadata-only snapshot reported as success). Any
* non-array parses to null = "all volumes" (the safe default).
*/
export function parseSelectedVolumes(raw: string | null | undefined): string[] | null {
if (!raw) return null;
let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { return null; }
if (!Array.isArray(parsed)) return null;
return parsed.filter((x): x is string => typeof x === 'string');
}
/**
* Parse a stored destination `policies` JSON blob. Bad/missing JSON → empty
* 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<string, any> {
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<string, string>): Record<stri
return out;
}
// SSRF URL-safety primitives now live in the neutral $lib/server/url-safety
// module so subsystems (backups, notifications) import DOWN into a shared home
// rather than the notification router importing UP from the backup engine.
// Re-exported here so existing backup-side importers keep resolving unchanged.
// SSRF URL-safety primitives live in the neutral $lib/server/url-safety module
// (shared by backups + notifications). Re-exported here so backup-side importers keep resolving.
export { isSafeWebhookUrl };
/**
@@ -384,56 +407,67 @@ export function buildResticEnv(
return env;
}
// restic global/backup/restore flags that are safe for an operator to append
// per destination. Anything that can read/write arbitrary files or run code
// (--option, --password-command, --cacert to an arbitrary path, etc.) is
// rejected. Flags may be `--flag` or `--flag=value`.
const RESTIC_FLAG_ALLOW = new Set([
// Restic flags a destination operator may append. Backup/global flags apply to backup +
// repo ops; restore flags apply ONLY to `restic restore` (which has its own flags like
// --exclude-xattr, #1349). The two are stored SEPARATELY (see BackupFlags), so a
// restore-only flag can never leak into `restic backup`. Anything that can read/write
// arbitrary files or run code (--option, --password-command, --cacert to an arbitrary
// path) is rejected. Flags may be `--flag` or `--flag=value`.
const RESTIC_BACKUP_FLAG_ALLOW = new Set([
'--limit-upload', '--limit-download', '--no-cache', '--cleanup-cache',
'--pack-size', '--compression', '--no-lock', '--json', '--quiet', '--verbose',
'--tls-client-cert', '--retry-lock', '--insecure-tls'
]);
// Restore adds --exclude-xattr (restore-only in restic; #1349 SELinux xattr the
// unprivileged helper can't remove on a cross-distro restore).
const RESTIC_RESTORE_FLAG_ALLOW = new Set([...RESTIC_BACKUP_FLAG_ALLOW, '--exclude-xattr']);
// Allowlisted flags that take a VALUE. Written joined (`--retry-lock=10m`) or
// space-separated (`--retry-lock 10m`); in the space-separated form the FOLLOWING
// token is that value, not a flag, so it legitimately doesn't start with `--`.
const RESTIC_FLAG_TAKES_VALUE = new Set([
'--limit-upload', '--limit-download', '--pack-size', '--compression',
'--tls-client-cert', '--retry-lock'
'--tls-client-cert', '--retry-lock', '--exclude-xattr'
]);
/**
* Split and validate a destination's extra restic CLI flags against an
* allowlist. Returns the accepted flag tokens; throws on any flag not in the
* allowlist so we never forward attacker-controlled options like `--option`,
* `--password-command`, or a bare non-flag argument. A value that follows a
* space-separated value-taking flag (e.g. the `10m` in `--retry-lock 10m`) is
* accepted as that flag's argument rather than treated as a flag itself.
*/
export function sanitizeResticFlags(flags: string | null | undefined): string[] {
/** Core allowlist validator, parameterized by which allowlist applies. */
function sanitizeAgainst(flags: string | null | undefined, allow: Set<string>): 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 });
}
/**
+10 -1
View File
@@ -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('|')}`;
}
+14 -6
View File
@@ -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=<name>-<type>-<env>`
* 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('/');
}
+5 -17
View File
@@ -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<Operation> {
return Operation.open(opts, onProgress);
}
/**
* Startup scrub: mark any operation left `running`/`queued` by a dead process as
* a terminal failure so it can't linger forever and inflate "in progress"
* metrics. Delegates to the shared DB sweep (which sets them failed) — a
* follow-up may split `stale` from `failed` at the DB layer, but the invariant
* (no row stays `running` across a restart) holds today.
*/
export async function scrubInterruptedOperations(): Promise<number> {
return failStaleRunningExecutions();
}
@@ -0,0 +1,58 @@
/**
* Pure transport predicates for the streaming put-archive path. Import-light (no docker.ts /
* db deps) so it's unit-testable — this is the edge-cap SAFEGUARD the whole streaming design
* leans on: if this wrongly reported an edge env as streamable, the uncapped stream path would
* run and the buffered+capped fallback would never engage (OOM risk).
*/
import { PassThrough, type Readable } from 'node:stream';
/** Minimal shape we branch on (a subset of DockerClientConfig). */
export interface TransportConfig {
connectionType?: string | null;
}
// A stalled tar upload has NO absolute timeout (a legit multi-GB tar can take a long time), so its
// only backstop is the helper's 6h ceiling - a half-dead agent that accepts the connection then
// stops reading would hang for hours. An IDLE (progress-based) watchdog distinguishes "slow but
// alive" from "dead": it fails only after N minutes of ZERO bytes flowing, and resets on every chunk.
export const TAR_IDLE_TIMEOUT_MS = Number(process.env.BACKUP_TAR_IDLE_MS) || 5 * 60_000;
/** Wrap a tar stream so it aborts (destroys) after TAR_IDLE_TIMEOUT_MS of zero throughput. The
* timer resets on every chunk that flows through (i.e. every chunk the receiver actually read via
* backpressure), so a stalled receiver trips it while a slow-but-progressing upload does not.
* Import-light (node:stream only) so it's unit-testable without docker.ts. */
export function withIdleWatchdog(tar: Readable, label: string, idleMs = TAR_IDLE_TIMEOUT_MS): Readable {
const out = new PassThrough();
let timer: ReturnType<typeof setTimeout> | null = null;
const arm = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const err = new Error(`${label}: no data flowed for ${Math.round(idleMs / 1000)}s - the target is not reading (stalled/dead); aborting upload`);
tar.destroy(err);
out.destroy(err);
}, idleMs);
};
const disarm = () => { if (timer) { clearTimeout(timer); timer = null; } };
// A chunk crossing the PassThrough means the receiver drained the previous one (backpressure) -
// real forward progress. Reset the idle timer on each.
out.on('data', arm);
out.on('end', disarm);
out.on('close', disarm);
tar.on('error', (e) => out.destroy(e));
arm();
return tar.pipe(out);
}
/** True when this env's transport can stream a request body with O(1) RAM. hawser-edge CANNOT
* (its WebSocket protocol base64s the whole tar into one frame); everything else (socket,
* direct, hawser-standard, and the local/undefined case) goes through node http/https core,
* which streams. Keep this the SINGLE source of the stream-vs-buffer decision. */
export function transportCanStream(config: TransportConfig): boolean {
return config.connectionType !== 'hawser-edge';
}
/** Strip shell/URL-dangerous chars from an archive path before it goes into the PUT query. */
export function sanitizeArchivePath(path: string): string {
return path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Idle-timeout policy for Docker API requests over http/https. Import-light + pure so it's
* unit-testable. Returns the socket idle timeout in ms, or null for NO timeout.
*
* A request whose BODY is a stream (a multi-GB tar PUT to /archive) must NOT get the 30s
* small-request idle timeout: it can legitimately stall >30s (slow disk read, mTLS
* backpressure, a GC pause) without being dead, and a 30s timer would destroy the transfer
* mid-upload leaving the helper a truncated tar. This is independent of how the RESPONSE is
* read (the response is tiny): body streaming and response streaming are tracked separately.
*/
export interface RequestTimeoutInput {
/** The request path (used to bump compose / prune to longer timeouts). */
path: string;
/** True when the request body is a stream (large upload) - suppresses the idle timeout. */
streamingBody: boolean;
/** True when the RESPONSE is consumed as a stream (logs/events follow) - also no timeout. */
streamingResponse: boolean;
/** COMPOSE_TIMEOUT env, seconds; defaults to 900. */
composeTimeoutSecs?: number;
}
export function computeRequestTimeoutMs(input: RequestTimeoutInput): number | null {
// No idle timeout when either end streams: a large body upload or a long-lived response.
if (input.streamingBody || input.streamingResponse) return null;
if (input.path === '/_hawser/compose') return (input.composeTimeoutSecs ?? 900) * 1000;
if (input.path.endsWith('/prune')) return 300000;
return 30000;
}
+19 -14
View File
@@ -16,17 +16,16 @@
* true source for a bind key, we FAIL LOUD rather than fall back to the slug.
*/
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 `<source>:/volumes/<key>:rw` bind + its `/volumes/<key>` include. `type` and
@@ -52,7 +55,7 @@ export interface VolumeBind {
/** Build the rw bind for a resolved source + key. */
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;
+19 -14
View File
@@ -1,5 +1,5 @@
/**
* backups/retention.ts — pure retention planning.
* backups/retention.ts - pure retention planning.
*
* Retention decides which snapshots restic keeps and which it forgets. Two
* 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<string, unknown>): RetentionPolicy {
let obj: Record<string, unknown>;
@@ -77,7 +77,13 @@ export function retentionActive(policy: RetentionPolicy): boolean {
* snapshots by tag. Returns null for `none` (nothing to do). The tag filter uses
* 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=<uuid>,dockhand:configid=<id>`
*/
@@ -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);
+47 -14
View File
@@ -1,13 +1,11 @@
/**
* backups/security.ts — the security surface for the backup/restore rewrite.
* backups/security.ts - the security surface for backup/restore.
*
* These are the hardened, well-tested primitives that keep restic invocation,
* 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/<name>; allow that path with the same strict name char class.
const SAFE_STACK_INCLUDE = /^\/metadata\/stacks\/[A-Za-z0-9._-]+$/;
export function isSafeVolumeInclude(inc: string): boolean {
// 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.
+190
View File
@@ -0,0 +1,190 @@
/**
* The ONE typed contract for a snapshot's metadata.json. Capture PRODUCES a
* SnapshotLayout (buildSnapshotLayout + serializeLayout); restore CONSUMES it
* (parseSnapshotLayout). Both ends share this type, so a drift between what capture
* writes and what restore reads is a COMPILE error, not a silent `undefined`.
*
* Pure and import-light (no db/docker/fs) so it is unit-testable directly.
*/
/** Schema version of the metadata.json payload. Bump ONLY on a breaking shape change.
* Backups are beta -> v1 is the first version. A snapshot written before this contract
* has NO version field; the parser accepts a missing version as v1 (see parse) so beta
* testers' existing snapshots keep restoring. */
export const SNAPSHOT_LAYOUT_VERSION = 1 as const;
export type VolumeKind = 'volume' | 'bind';
/** One captured data channel. `key` is the /volumes/<key> segment restic lists and the
* restore UI indexes on. `source` is the bind's ABSOLUTE host path, or (for a named
* volume) the volume name - what an in-place restore rebinds to. */
export interface SnapshotVolume {
key: string;
name: string;
source: string;
type: VolumeKind;
}
/** A stack secret carried IN the snapshot: `value` is at-rest ciphertext (enc:v1:...),
* NEVER plaintext. materialise writes these to the DB on restore; the UI shows only the
* `key`s. */
export interface SnapshotSecret {
key: string;
value: string;
}
/** The stack half of a snapshot. Present iff type==='stack' AND a compose file was
* resolved at capture. The stack dir ALWAYS lives at /volumes/__dockhand_stackdir__ (the
* helper's host bind mount) -> no path is stored; restore uses stackDirSource(). */
/** One captured stack file, for browse/debug: relative path + byte size. */
export interface SnapshotStackFile {
path: string;
bytes: number;
}
export interface SnapshotStack {
composeFileName: string;
fileList: SnapshotStackFile[];
excludedBindDirs: string[];
secrets: SnapshotSecret[];
}
/** The typed metadata.json payload. This IS the snapshot contract. `hasStackFiles` is
* NOT a field here on purpose: it is DERIVED as `layout.stack !== undefined`, so a flag
* can never disagree with the actual captured stack. */
export interface SnapshotLayout {
version: typeof SNAPSHOT_LAYOUT_VERSION;
type: 'container' | 'stack';
targetName: string;
environmentId: number | null;
backupTime: string;
volumes: SnapshotVolume[];
/** Present only for a container backup (recreate-as-is). Opaque inspect JSON. */
container?: unknown;
/** Present only for a stack backup with a resolved compose. */
stack?: SnapshotStack;
}
/** Capture-side builder. The single place a SnapshotLayout is constructed - no bare
* `metadata.x = ...` bag anywhere else. */
export function buildSnapshotLayout(input: {
type: 'container' | 'stack';
targetName: string;
environmentId: number | null;
backupTime: string;
volumes: SnapshotVolume[];
container?: unknown;
stack?: SnapshotStack;
}): SnapshotLayout {
return { version: SNAPSHOT_LAYOUT_VERSION, ...input };
}
/** Serialize for writing to /metadata/metadata.json (pretty, matching the prior format). */
export function serializeLayout(layout: SnapshotLayout): string {
return JSON.stringify(layout, null, 2);
}
function isVolumeKind(v: unknown): v is VolumeKind {
return v === 'volume' || v === 'bind';
}
/** Parse+validate a raw metadata.json string into a typed SnapshotLayout, or null on ANY
* problem (bad JSON, wrong version, missing/wrong-typed required field). NEVER throws:
* restore turns null into an explicit, logged "metadata unreadable" branch instead of a
* cascade of undefineds. A missing `version` is treated as v1 (pre-contract beta
* snapshot). Unknown FUTURE versions return null (we can't safely read them). */
export function parseSnapshotLayout(raw: string): SnapshotLayout | null {
let o: any;
try { o = JSON.parse(raw); } catch { return null; }
if (!o || typeof o !== 'object') return null;
// Accept missing version (pre-contract beta) as v1; reject any other value.
const version = o.version === undefined ? SNAPSHOT_LAYOUT_VERSION : o.version;
if (version !== SNAPSHOT_LAYOUT_VERSION) return null;
if (o.type !== 'container' && o.type !== 'stack') return null;
if (typeof o.targetName !== 'string') return null;
const environmentId = typeof o.environmentId === 'number' ? o.environmentId : null;
const backupTime = typeof o.backupTime === 'string' ? o.backupTime : '';
// volumes[]: coerce defensively, drop any malformed entry rather than failing the whole
// parse (a snapshot with one odd volume entry should still restore the rest).
const volumes: SnapshotVolume[] = Array.isArray(o.volumes)
? o.volumes
.filter((v: any) => v && typeof v.key === 'string' && isVolumeKind(v.type))
.map((v: any) => ({
key: v.key as string,
name: typeof v.name === 'string' ? v.name : v.key,
source: typeof v.source === 'string' ? v.source : '',
type: v.type as VolumeKind,
}))
: [];
let stack: SnapshotStack | undefined;
if (o.stack && typeof o.stack === 'object' && typeof o.stack.composeFileName === 'string') {
stack = {
composeFileName: o.stack.composeFileName,
fileList: Array.isArray(o.stack.fileList)
? o.stack.fileList
.filter((f: any) => f && typeof f.path === 'string' && typeof f.bytes === 'number')
.map((f: any) => ({ path: f.path as string, bytes: f.bytes as number }))
: [],
excludedBindDirs: Array.isArray(o.stack.excludedBindDirs) ? o.stack.excludedBindDirs.filter((x: any) => typeof x === 'string') : [],
secrets: Array.isArray(o.stack.secrets)
? o.stack.secrets
.filter((s: any) => s && typeof s.key === 'string' && typeof s.value === 'string')
.map((s: any) => ({ key: s.key as string, value: s.value as string }))
: [],
};
}
return {
version: SNAPSHOT_LAYOUT_VERSION,
type: o.type,
targetName: o.targetName,
environmentId,
backupTime,
volumes,
container: o.container,
stack,
};
}
/** Shape returned to the client: same as SnapshotLayout but with all secret-bearing
* fields removed. `container` inspect keeps its shape MINUS Config.Env/Labels; stack
* secrets collapse to names. */
export type RedactedSnapshotLayout = Omit<SnapshotLayout, 'container' | 'stack'> & {
container?: unknown;
hasStackFiles: boolean;
stack?: (Omit<SnapshotStack, 'secrets'> & { secretKeys: string[] });
};
/** SINGLE redaction point for any snapshot metadata that reaches the client (the
* metadata route AND the dump route serving metadata.json). Strips:
* - stack.secrets VALUES (at-rest ciphertext) -> secretKeys names only
* - container inspect Config.Env (plaintext env vars: DB passwords, API keys, tokens)
* and Config.Labels (compose can stash secrets in labels)
* The full inspect is kept server-side for recreate; it must NEVER leave the process
* un-redacted. `backups:view` is a weaker grant than `containers:view`, so leaking the
* inspect here would defeat RBAC secret-hiding. */
export function redactSnapshotLayout(metadata: SnapshotLayout): RedactedSnapshotLayout {
const { stack, container, ...rest } = metadata;
return {
...rest,
hasStackFiles: stack !== undefined,
container: redactContainerInspect(container),
stack: stack
? { ...stack, secrets: undefined as never, secretKeys: stack.secrets.map((s) => s.key) } as any
: undefined,
} as RedactedSnapshotLayout;
}
/** Remove secret-bearing keys from an opaque Docker inspect object (Config.Env, Config.Labels).
* Returns the value untouched if it isn't the expected shape (defensive - never throw). */
function redactContainerInspect(container: unknown): unknown {
if (!container || typeof container !== 'object') return container;
const c = container as any;
if (!c.Config || typeof c.Config !== 'object') return container;
const { Env, Labels, ...configRest } = c.Config;
return { ...c, Config: { ...configRest, Env: undefined, Labels: undefined } };
}
+286
View File
@@ -0,0 +1,286 @@
/**
* stackdir-plan.ts — the ONE place that decides WHICH host folder is captured as a
* stack's directory. Pure and import-light (no db/docker/fs), so it is unit-testable
* directly and can't drift into scattered `connectionType` branches.
*
* MAIN FLOW: the backup helper bind-mounts the stack folder from the TARGET env's HOST at
* /volumes/__dockhand_stackdir__:ro, and restic reads it from disk - the helper is a sibling
* container on the target daemon, so a host path on that daemon is mountable. The snapshot
* reflects the stack folder AS IT IS ON THE HOST (what the user actually edits).
*
* ONE EXCEPTION - a direct-REMOTE env with NO remote_stacks_dir: nothing is staged on the host
* (deploy went via stdin), so there is no host folder to mount. The caller then tars the
* compose/config from Dockhand's OWN local copy into the SAME snapshot location instead of
* hard-failing. This is the only case that doesn't reflect the host 1:1 - because the host has
* nothing to reflect.
*
* This module resolves the CANDIDATE host path (see resolveHostStackDir). A runtime probe
* (helper bind + `test -f <composeFile>`) then proves the path is real on the target daemon
* before capture; if it isn't (e.g. a direct env whose working_dir is Dockhand's own
* /app/data path, not the host's), capture HARD-FAILS to data-only rather than silently
* backing up an empty auto-created dir.
*/
/** The reserved /volumes key the stack dir is captured under. Long and namespaced so a real
* user volume can't realistically collide with it. */
export const STACKDIR_VOLUME_KEY = '__dockhand_stackdir__';
/**
* True for a reserved /volumes key that is NOT a user data volume — currently just the
* stack-dir capture. Restore uses it to keep this key OUT of everything that treats a
* /volumes/ entry as restorable container data (the volume picker, "restore all", the
* in-place swap). The stack dir is restored ONLY via the redeploy/materialise path.
*/
export function isReservedVolumeKey(key: string): boolean {
return key === STACKDIR_VOLUME_KEY;
}
/**
* Where a snapshot's captured stack dir lives, for restore to read it back: ALWAYS
* /volumes/__dockhand_stackdir__. `include` is for `restic restore --include`; `extractSub`
* is the on-disk subpath the restore lands under its --target.
*/
export function stackDirSource(): { include: string; extractSub: string } {
return { include: `/volumes/${STACKDIR_VOLUME_KEY}`, extractSub: `volumes/${STACKDIR_VOLUME_KEY}` };
}
/**
* Derive the host stack dir from a RELATIVE compose bind and the host path the daemon reports
* for it. This is the MOST AUTHORITATIVE source: the Docker daemon tells us exactly where the
* bind lives on its host (`mount.Source`), and compose resolves a relative source (`./html`)
* against the stack dir - so `hostStackDir = <mount.Source> with the relative tail removed`.
*
* Example: compose `./html:/x` on host `/docker/data/dockhand/stacks/anton/pppppp` gives the
* daemon `mount.Source = /docker/data/dockhand/stacks/anton/pppppp/html`; with relSource
* `./html` we strip `/html` and get the stack dir. Works on socket, direct-local, adopted, and
* hawser alike (the daemon always knows its own bind sources), and needs no DATA_DIR/HOST_DATA_DIR
* config. Returns null when the source isn't relative or the tail doesn't match (absolute bind,
* `${VAR}` source, etc.) - the caller then falls back to translation/label.
*
* @param relSource the compose bind source as written (e.g. `./html`, `./`, `../x`)
* @param hostSource the absolute host path the daemon reports for that bind (`mount.Source`)
*/
export function hostStackDirFromBind(relSource: string, hostSource: string): string | null {
if (typeof relSource !== 'string' || typeof hostSource !== 'string') return null;
const rs = relSource.trim();
const hs = hostSource.trim().replace(/\/+$/, '');
if (!hs.startsWith('/')) return null; // host source must be absolute
if (!/^\.\.?\//.test(rs) && rs !== '.' && rs !== './') return null; // only relative sources
// The relative tail below the stack dir: `./html` -> `html`, `./` or `.` -> '' (bind IS the
// stack dir). Reject `../` sources: the bind is ABOVE the stack dir, so we can't derive it.
let tail = rs.replace(/^\.\//, '').replace(/\/+$/, '');
if (tail === '.' || tail === '') {
return hs; // the whole stack dir is bind-mounted (`./:/app`) -> host source IS the stack dir
}
if (tail.startsWith('../') || tail === '..') return null;
// Strip the `/tail` suffix from the host source. Must actually end with it, or the mapping
// is inconsistent (don't guess).
const suffix = '/' + tail;
if (!hs.endsWith(suffix)) return null;
const dir = hs.slice(0, hs.length - suffix.length);
return dir === '' ? '/' : dir;
}
/**
* Derive the host stack dir by matching the compose's relative bind DIRS against the daemon's
* reported bind sources. For each relative dir the compose declares (`html`, from `./html:/x`),
* find a discovered bind whose host source ENDS WITH `/html` and strip that tail - the remainder
* is the stack dir. Returns the first consistent match, or null if none. Pure + unit-testable.
*
* `relBindDirs`: relative dir names from relativeBindDirsFromCompose (e.g. ['html','data']).
* `bindSources`: the daemon-reported host source paths of the stack's bind mounts (mount.Source).
*/
export function deriveStackDirFromBinds(relBindDirs: string[], bindSources: string[]): string | null {
for (const rel of relBindDirs) {
const tail = '/' + rel.replace(/^\/+|\/+$/g, '');
for (const src of bindSources) {
const derived = hostStackDirFromBind('./' + rel.replace(/^\/+/, ''), src);
if (derived) return derived;
// Also handle a nested relative dir (`./conf/nginx`) whose source ends with the tail.
const hs = typeof src === 'string' ? src.trim().replace(/\/+$/, '') : '';
if (hs.endsWith(tail)) { const d = hs.slice(0, hs.length - tail.length); return d === '' ? '/' : d; }
}
}
return null;
}
/**
* Whether a bind-derived host path can be TRUSTED for this env. bind-derived comes from the
* target daemon's reported mount.Source, which is a REAL host path for socket / direct-LOCAL /
* adopted / hawser (the daemon shares its host with the stack). But for a direct-REMOTE env with
* NO remote_stacks_dir, the stack was deployed VIA STDIN with working_dir = Dockhand's own
* container path; `docker compose` then asks the remote daemon to bind `./data`, and the daemon
* mkdir's a PHANTOM EMPTY dir at that Dockhand-container path on ITS host. Stripping the tail
* yields a dir that exists remotely but holds NO compose (never staged) -> the probe HARD-FAILS.
* So distrust bind-derived in exactly that case, letting the caller fall through to kind:'tar'
* (capture the compose/config from Dockhand's own local copy). When remote_stacks_dir IS set the
* deploy staged the files there and bind-derived pins the real path, so keep it. Pure + testable.
*/
export function trustBindDerivedForEnv(
bindDerivedHostPath: string | null,
opts: { directRemote: boolean; hasRemoteStacksDir: boolean }
): string | null {
if (opts.directRemote && !opts.hasRemoteStacksDir) return null;
return bindDerivedHostPath;
}
export type StackDirEntry = { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' };
/**
* Tag each stack-dir entry that is ALSO captured as its own volume/bind channel, so the picker
* can show it as non-deselectable ("captured as a bind mount below") - the user controls it in
* the Volumes section, not here. An entry is a bind when EITHER `<hostPath>/<name>` matches a
* daemon-reported bind source (runtime) OR its name is a compose relative-bind dir. The compose
* source is load-bearing: `bindSources` comes from inspecting the RUNNING containers, so a STOPPED
* stack reports none and a bind dir (e.g. `./memos`) would wrongly show as a plain, deselectable
* stack file. `bindDirNames` (from the compose, always available) fixes that. Pure + unit-testable.
*/
export function tagCapturedEntries(entries: StackDirEntry[], hostPath: string, bindSources: string[], bindDirNames: string[] = []): StackDirEntry[] {
const base = hostPath.replace(/\/+$/, '');
const bindSet = new Set(bindSources.map((s) => (typeof s === 'string' ? s.replace(/\/+$/, '') : '')));
const nameSet = new Set(bindDirNames);
return entries.map((e) =>
(bindSet.has(`${base}/${e.name}`) || nameSet.has(e.name)) ? { ...e, capturedAs: 'bind' as const } : e
);
}
/**
* Parse the probe container's directory listing. The probe runs
* `find /probe -maxdepth 1 -mindepth 1 -printf '%y\t%s\t%f\n'`, so each line is
* `<type>\t<size>\t<name>` where type is 'd' (dir) / 'f' (file) / other. Pure + unit-testable.
* Names may contain spaces but not tabs or newlines (find's %f), so tab-splitting is safe.
* Dirs sort before files, then alphabetical.
*/
export function parseProbeListing(stdout: string): StackDirEntry[] {
const out: StackDirEntry[] = [];
for (const line of stdout.split('\n')) {
if (!line) continue;
const tab1 = line.indexOf('\t');
const tab2 = line.indexOf('\t', tab1 + 1);
if (tab1 < 0 || tab2 < 0) continue;
const t = line.slice(0, tab1);
const size = parseInt(line.slice(tab1 + 1, tab2), 10);
const name = line.slice(tab2 + 1);
if (!name) continue;
out.push({ name, type: t === 'd' ? 'dir' : 'file', size: Number.isFinite(size) ? size : 0 });
}
return out.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
}
/** Sentinel the probe list-script prints when the target path does NOT exist, so the classifier can
* tell "helper ran, dir missing" apart from "helper ran, dir empty" (both otherwise list nothing). */
export const PROBE_MISSING_SENTINEL = '__PROBE_MISSING__';
/** Data-presence verdict for a probed host path / volume. `helper-failed` (the container could not
* run at all) is decided by the CALLER catching the run error - the classifier only sees stdout of
* a helper that DID run, so it returns has-data / empty / missing. */
export type ProbeDataKind = 'has-data' | 'empty' | 'missing' | 'helper-failed';
/** Classify a probe helper's stdout (pure, unit-testable). The list-script prints the missing
* sentinel when the path doesn't exist, one `<type>\t<size>\t<name>` line per entry otherwise. */
export function classifyProbeListing(stdout: string): 'has-data' | 'empty' | 'missing' {
if (stdout.includes(PROBE_MISSING_SENTINEL)) return 'missing';
return parseProbeListing(stdout).length > 0 ? 'has-data' : 'empty';
}
/**
* Is the TARGET daemon Dockhand's OWN host? Only then are the DATA_DIR / container-mount
* translations valid: they map a container path to DOCKHAND'S host (via HOST_DATA_DIR /
* Dockhand's own mounts), which is the WRONG host for a remote daemon. For a remote env the
* stack lives on a DIFFERENT machine, so those translations would hand the helper a path that
* only exists on Dockhand's host, not the remote one. Pure + unit-testable.
* - socket / null: local (Dockhand shares the daemon's host).
* - direct: local ONLY when the env's tcp host == Dockhand's own docker host (#1203 proof).
* - hawser: never local.
*/
export function isLocalDaemon(connectionType: string | null, envTcpHost: string | null, ownDockerHost: string | null): boolean {
if (connectionType && connectionType.startsWith('hawser')) return false;
if (connectionType === 'socket' || connectionType == null) return true;
if (connectionType === 'direct') return !!(ownDockerHost && envTcpHost && ownDockerHost === envTcpHost);
return false;
}
/** Result of resolving the candidate host stack folder (before the runtime probe). */
export type HostStackDirResolution =
| { kind: 'candidate'; hostPath: string; composeFile: string; source: string }
| { kind: 'unknown'; reason: string };
/** Inputs for the pure host-path resolution. All the I/O (compose lookup, DATA_DIR/mount
* translation, label read) is done by the CALLER and injected here, so this stays pure and
* unit-testable. The caller passes whichever candidates it could compute; this picks the
* right one in priority order. */
export interface HostStackDirInput {
/** basename of Dockhand's compose file for the stack (immich.yaml / docker-compose.yml).
* The AUTHORITATIVE compose name - NOT the config_files label, which is `-` when Dockhand
* deploys via stdin (`-f -`). */
composeFileName: string | null;
/** For a direct-REMOTE env with remote_stacks_dir set: the explicit, user-declared host
* path (<remote_stacks_dir>/<stack>) where the deploy staged the files. Highest priority
* after a bind pins the dir more exactly. Null for socket/hawser/local. */
remoteStacksDirHostPath?: string | null;
/** Host stack dir DERIVED from a relative compose bind's daemon-reported source
* (hostStackDirFromBind). The MOST AUTHORITATIVE source - the daemon knows exactly where its
* bind lives on the host, so this needs no DATA_DIR/HOST_DATA_DIR config and works on socket,
* direct-local, adopted, and hawser. Null when the stack has no usable relative bind. */
bindDerivedHostPath: string | null;
/** Dockhand's stack dir translated to the host via DATA_DIR -> HOST_DATA_DIR. Non-null
* (and != the container path) when the stack lives under Dockhand's DATA_DIR and the host
* data dir is known - the SOCKET/LOCAL Dockhand-deployed case. Fallback when no bind. */
dataDirHostPath: string | null;
/** Dockhand's stack dir translated to the host via a container bind mount, for an
* adopted/external stack that lives OUTSIDE DATA_DIR but on a mounted host path. */
mountHostPath: string | null;
/** com.docker.compose.project.working_dir label. For a HAWSER agent (which ran compose on
* the remote host) or a matching-paths direct env, this IS the real host path. Used only
* when neither translation applies (the daemon is not Dockhand's own host). */
workingDirLabel: string | null;
}
/**
* Resolve the CANDIDATE host stack folder. ONE FLOW still: the helper always bind-mounts this
* path from the target daemon's host. The subtlety is that "where is the stack folder ON THAT
* DAEMON'S HOST" differs by wiring, and the compose `working_dir` label is NOT reliable on its
* own (it is the container-view path for a Dockhand-deployed stack, and `config_files` is `-`
* for stdin deploys). So the caller computes the candidates and we pick in priority order:
* 1. `bindDerivedHostPath` - derived from a relative bind's daemon-reported source. The daemon
* knows exactly where its bind lives on the host, so this is authoritative and needs no
* DATA_DIR config. Works on socket / direct-local / adopted / hawser.
* 2. `dataDirHostPath` - Dockhand deployed the stack under DATA_DIR and we know the host data
* dir (SOCKET/LOCAL). The label `/app/data/...` would be the CONTAINER path; the helper on
* the host needs `/docker/data/dockhand/...`.
* 3. `mountHostPath` - adopted/external stack outside DATA_DIR but on a mounted host path.
* 4. `workingDirLabel` - HAWSER (agent ran compose on the remote host, label = host path) or
* matching-paths direct. Only when nothing above applied.
* 5. none -> unknown (caller HARD-FAILS a stack backup; option A).
* A runtime probe (`test -f <composeFile>` in the helper) then CONFIRMS reachability before
* capture. Pure + unit-testable. */
export function resolveHostStackDir(input: HostStackDirInput): HostStackDirResolution {
// The probe's `test -f` needs a real compose name. `config_files` is `-` for stdin deploys,
// so we take the name from Dockhand's own compose file (composeFileName), falling back to the
// conventional name.
const composeFile = input.composeFileName && input.composeFileName !== '-'
? input.composeFileName
: 'docker-compose.yml';
const norm = (p: string | null | undefined): string => (typeof p === 'string' ? p.trim().replace(/\/+$/, '') : '');
const viaBind = norm(input.bindDerivedHostPath);
if (viaBind) return { kind: 'candidate', hostPath: viaBind, composeFile, source: 'derived from a relative compose bind (daemon-reported host source)' };
const viaRemoteDir = norm(input.remoteStacksDirHostPath);
if (viaRemoteDir) return { kind: 'candidate', hostPath: viaRemoteDir, composeFile, source: 'declared host stack path (direct-remote remote_stacks_dir / hawser agent STACKS_DIR)' };
const viaData = norm(input.dataDirHostPath);
if (viaData) return { kind: 'candidate', hostPath: viaData, composeFile, source: 'DATA_DIR -> HOST_DATA_DIR translation (Dockhand-deployed, local)' };
const viaMount = norm(input.mountHostPath);
if (viaMount) return { kind: 'candidate', hostPath: viaMount, composeFile, source: 'container mount translation (adopted/external stack)' };
const wd = norm(input.workingDirLabel);
if (wd) return { kind: 'candidate', hostPath: wd, composeFile, source: 'compose working_dir label (hawser agent / matching paths)' };
return { kind: 'unknown', reason: 'could not locate the stack folder on the host (no DATA_DIR/mount translation and no working_dir label)' };
}
+26 -30
View File
@@ -1,44 +1,35 @@
/**
* backups/swap.ts — the shell scripts that make an in-place volume restore safe.
* 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 `<live>/.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 `<live>/.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};`,
+13 -1
View File
@@ -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)) {
-12
View File
@@ -164,18 +164,6 @@ export async function startScheduler(): Promise<void> {
console.warn(`[Scheduler] Orphan helper reap failed: ${err instanceof Error ? err.message : String(err)}`);
}
// 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
+48
View File
@@ -0,0 +1,48 @@
/**
* Pump a web ReadableStream into a node Writable (an http/https request) WITH backpressure.
* Import-light (no server deps) so it's unit-testable.
*
* The bug this guards against: a naive `while (read()) req.write(value)` ignores write()'s
* return value. write() returns false when the socket send-buffer is full; without awaiting
* 'drain' before the next read, a fast disk source feeding a slow (mTLS) peer piles the WHOLE
* payload into RAM (O(n) - measured 500MB -> +564MB RSS). Awaiting 'drain' bounds RAM to the
* socket high-water mark (O(1), +5MB), matching what node's .pipe() does for free on the
* socket/plain-http path.
*/
/** Minimal writable surface we depend on (http.ClientRequest satisfies this). */
export interface BackpressureWritable {
write(chunk: Uint8Array): boolean;
end(): void;
destroy(err?: Error): void;
once(event: 'drain', cb: () => void): void;
}
/** Minimal reader surface (a ReadableStreamDefaultReader satisfies this). */
export interface ChunkReader {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
}
/** Await a single 'drain' from the writable. */
function waitDrain(w: BackpressureWritable): Promise<void> {
return new Promise((resolve) => w.once('drain', resolve));
}
/**
* Read every chunk and write it, awaiting 'drain' whenever write() signals backpressure.
* Calls `end()` on completion. On any error, `destroy(err)` is called and the error rethrown.
*/
export async function pumpWebStreamToWritable(reader: ChunkReader, w: BackpressureWritable): Promise<void> {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value === undefined) continue;
if (!w.write(value)) await waitDrain(w);
}
w.end();
} catch (err) {
w.destroy(err as Error);
throw err;
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Pure tally + summary for the "Test all destinations" action. A destination test
* has three outcomes, not two: a reachable-but-uninitialized repo is `needs_init`,
* NOT a failure. Kept dependency-free so it's unit-testable without the DOM.
*/
export type TestOutcome = 'success' | 'needs_init' | 'failed';
export interface TestCounts {
passed: number;
failed: number;
needsInit: number;
}
/** Count each three-way test outcome. Anything not 'success'/'needs_init' is a failure. */
export function summarizeTestResults(outcomes: TestOutcome[]): TestCounts {
const counts: TestCounts = { passed: 0, failed: 0, needsInit: 0 };
for (const o of outcomes) {
if (o === 'success') counts.passed++;
else if (o === 'needs_init') counts.needsInit++;
else counts.failed++;
}
return counts;
}
/**
* Toast text + severity for a tally. Real failures make it an error; needs-init
* without failures is a warning (action needed, but nothing is broken); all-pass
* is a success.
*/
export function formatTestSummary(counts: TestCounts): {
text: string;
severity: 'success' | 'warning' | 'error';
} {
const { passed, failed, needsInit } = counts;
const parts: string[] = [`${passed} passed`];
if (failed > 0) parts.push(`${failed} failed`);
if (needsInit > 0) parts.push(`${needsInit} need init`);
if (failed > 0) return { text: parts.join(', '), severity: 'error' };
if (needsInit > 0) return { text: parts.join(', '), severity: 'warning' };
return { text: `All ${passed} destinations tested & stats collected`, severity: 'success' };
}
+5 -6
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import 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();
@@ -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 });
@@ -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 });
@@ -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 });
@@ -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 });
@@ -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;
@@ -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);
@@ -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);
@@ -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, '_');
@@ -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<string, unknown>;
if (Array.isArray(secrets)) {
(safe as Record<string, unknown>).secretKeys = secrets
.map((s: any) => s?.key)
.filter((k: unknown): k is string => typeof k === 'string');
}
return json(safe);
// 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 });
@@ -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');
+35 -16
View File
@@ -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)}
</button>
{/each}
</div>
@@ -1076,14 +1091,16 @@
onclick={() => handleEntryClick(entry)}
>
<Icon
class="w-3.5 h-3.5 shrink-0 {entry.type === 'directory'
? 'text-blue-500'
: entry.type === 'symlink'
? 'text-purple-500'
: 'text-muted-foreground'}"
class="w-3.5 h-3.5 shrink-0 {isSnapshotMode && entry.name === '__dockhand_stackdir__'
? 'text-amber-500'
: entry.type === 'directory'
? 'text-blue-500'
: entry.type === 'symlink'
? 'text-purple-500'
: 'text-muted-foreground'}"
/>
<span class="truncate" title={entry.name}>
{entry.name}
{displayEntryName(entry.name)}
{#if entry.type === 'symlink' && entry.linkTarget}
<span class="text-muted-foreground ml-1">
→ {entry.linkTarget}
@@ -1188,15 +1205,17 @@
{/snippet}
</ConfirmPopover>
{/if}
<Button
variant="ghost"
size="icon"
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
title="Download"
>
<Download class="w-3 h-3" />
</Button>
{#if canDownloadEntry(entry)}
<Button
variant="ghost"
size="icon"
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
title="Download"
>
<Download class="w-3 h-3" />
</Button>
{/if}
</div>
</Table.Cell>
</Table.Row>
+26 -40
View File
@@ -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 @@
<Dialog.Root bind:open>
<Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 flex-wrap">
<FolderOpen class="w-5 h-5" />
Browse snapshot
{#if snapshotEnvName}
<span class="text-muted-foreground font-normal">from</span>
<EnvironmentIcon icon={snapshotEnvIcon} envId={snapshotEnvId} class="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span class="font-normal text-amber-600 dark:text-amber-400">{snapshotEnvName}</span>
{/if}
{#if displayName}
<span class="text-muted-foreground font-normal">—</span>
{#if (targetType || metadata?.type) === 'stack'}
<Layers class="w-4 h-4 text-purple-500" />
{:else}
<Box class="w-4 h-4 text-blue-500" />
{/if}
<span class="font-normal">{displayName}</span>
{/if}
{#if destinationName}
<span class="text-muted-foreground font-normal">on</span>
<svelte:component this={getRepoTypeIcon(destinationRepo)} class="w-4 h-4" />
<span class="font-normal">{destinationName}</span>
{/if}
<Dialog.Title>
<SnapshotHeader
icon={FolderOpen}
verb="Browse snapshot"
name={displayName}
nameType={(targetType || metadata?.type) === 'stack' ? 'stack' : 'container'}
{destinationName}
destinationRepository={destinationRepo}
sourceEnv={{ id: snapshotEnvId, icon: snapshotEnvIcon }}
sourceEnvName={snapshotEnvName}
snapshotId={snapshotId ? String(snapshotId) : undefined}
{snapshotTime}
/>
</Dialog.Title>
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground">
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
{#if snapshotTime}{formatDateTime(snapshotTime)} <span class="opacity-60">({formatRelativeTime(snapshotTime)})</span>{/if}
</Dialog.Description>
<Dialog.Description class="sr-only">Browse files in snapshot {snapshotId ? String(snapshotId).slice(0, 8) : ''}.</Dialog.Description>
</Dialog.Header>
<!-- Tabs -->
@@ -192,12 +181,12 @@
<dt class="text-muted-foreground">Volumes</dt>
<dd>{metadata.volumes?.length ?? 0}</dd>
{#if metadata.type === 'stack'}
{#if metadata.composeFileName}
{#if metadata.stack?.composeFileName}
<dt class="text-muted-foreground">Compose file</dt>
<dd class="font-mono">{metadata.composeFileName}</dd>
<dd class="font-mono">{metadata.stack.composeFileName}</dd>
{/if}
<dt class="text-muted-foreground">Stack files</dt>
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd>
<dd>{metadata.hasStackFiles ? 'captured' : 'not captured'}</dd>
{/if}
</dl>
</section>
@@ -229,34 +218,31 @@
<!-- Stack files (captured under stackfiles/). The list is recorded in
metadata.json at backup time so we can enumerate the exact files
without a browse roundtrip. -->
{#if metadata.type === 'stack' && metadata.stackFilesList?.length}
{#if metadata.type === 'stack' && metadata.stack?.fileList?.length}
<section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stack.fileList.length})</h4>
<div class="space-y-1">
{#each metadata.stackFilesList as file}
{#each metadata.stack.fileList as file}
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.composeFileName}>{file.path}</span>
{#if file.path === metadata.composeFileName}
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.stack.composeFileName}>{file.path}</span>
{#if file.path === metadata.stack.composeFileName}
<Badge variant="outline" class="text-[10px] shrink-0">compose</Badge>
{/if}
<span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span>
</div>
{/each}
</div>
{#if metadata.stackFilesListTruncated}
<p class="text-xs text-muted-foreground">Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.</p>
{/if}
</section>
{/if}
<!-- Secrets carried in the snapshot (KEY NAMES only — the values are
stored encrypted and never exposed here). Restored to the target
DB on restore unless the user opts out. -->
{#if metadata.type === 'stack' && metadata.secretKeys?.length}
{#if metadata.type === 'stack' && metadata.stack?.secretKeys?.length}
<section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.secretKeys.length})</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.stack.secretKeys.length})</h4>
<div class="flex flex-wrap gap-1">
{#each metadata.secretKeys as key}
{#each metadata.stack.secretKeys as key}
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
{/each}
</div>