backup engine internals - streaming, timeouts, typed snapshot layout, volume keys, route guards
This commit is contained in:
32 files changed
+1205
-352
No files matched your search
@@ -3,6 +3,24 @@
|
|||||||
* snapshot browser. No docker, no DB, so it is unit-testable directly.
|
* snapshot browser. No docker, no DB, so it is unit-testable directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** One restic `ls --json` line. restic emits a `snapshot` header then one entry per node.
|
||||||
|
* The node discriminator is `message_type: 'node'` in current restic; older restic used
|
||||||
|
* `struct_type: 'node'` (now deprecated). We accept EITHER so a restic bump that drops the
|
||||||
|
* old field can't silently empty the browser. */
|
||||||
|
export interface ResticLsNode {
|
||||||
|
message_type?: string;
|
||||||
|
struct_type?: string;
|
||||||
|
path?: string;
|
||||||
|
type?: string;
|
||||||
|
size?: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if a parsed restic ls line is a filesystem node (not the `snapshot` header). */
|
||||||
|
function isResticNode(e: ResticLsNode): boolean {
|
||||||
|
return e.message_type === 'node' || e.struct_type === 'node';
|
||||||
|
}
|
||||||
|
|
||||||
/** Normalize a path for self-comparison: collapse repeated slashes at the ends,
|
/** Normalize a path for self-comparison: collapse repeated slashes at the ends,
|
||||||
* always leading-slash, no trailing slash (root stays "/"). */
|
* always leading-slash, no trailing slash (root stays "/"). */
|
||||||
function normalizePath(p: string): string {
|
function normalizePath(p: string): string {
|
||||||
@@ -20,15 +38,15 @@ function normalizePath(p: string): string {
|
|||||||
* directory (e.g. an empty `volumes/` inside `/volumes/`, `bt-vol/` inside
|
* directory (e.g. an empty `volumes/` inside `/volumes/`, `bt-vol/` inside
|
||||||
* `/volumes/bt-vol/`). Keep only nodes whose path is NOT the queried path.
|
* `/volumes/bt-vol/`). Keep only nodes whose path is NOT the queried path.
|
||||||
*/
|
*/
|
||||||
export function parseSnapshotLsEntries(stdout: string, queriedPath: string): any[] {
|
export function parseSnapshotLsEntries(stdout: string, queriedPath: string): ResticLsNode[] {
|
||||||
const queried = normalizePath(queriedPath);
|
const queried = normalizePath(queriedPath);
|
||||||
const entries: any[] = [];
|
const entries: ResticLsNode[] = [];
|
||||||
for (const line of stdout.split('\n')) {
|
for (const line of stdout.split('\n')) {
|
||||||
const t = line.trim();
|
const t = line.trim();
|
||||||
if (!t) continue;
|
if (!t) continue;
|
||||||
let e: any;
|
let e: ResticLsNode;
|
||||||
try { e = JSON.parse(t); } catch { continue; }
|
try { e = JSON.parse(t); } catch { continue; }
|
||||||
if (e?.struct_type !== 'node') continue;
|
if (!isResticNode(e)) continue;
|
||||||
if (normalizePath(e.path ?? '') === queried) continue; // self-node, not a child
|
if (normalizePath(e.path ?? '') === queried) continue; // self-node, not a child
|
||||||
entries.push(e);
|
entries.push(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { isUnbackupableBindSource } from '../../utils/unbackupable-mounts';
|
import { isUnbackupableBindSource } from '../../utils/unbackupable-mounts';
|
||||||
|
import { safeKey, volumeDedupKey, volumeStorageKey } from '../../utils/volume-identity';
|
||||||
|
|
||||||
|
// Capture and the UI picker share ONE key allocator (utils/volume-identity) so they can't drift.
|
||||||
|
// Re-exported so server callers (restore-core.ts) import safeKey from here.
|
||||||
|
export { safeKey };
|
||||||
|
|
||||||
|
/** The `/volumes/<key>` mount point a captured/restored volume rides on. ONE definition of the
|
||||||
|
* mount-point contract so backup capture (ro), restore (rw), and the preview can't drift on it. */
|
||||||
|
export function volumeInclude(key: string): string {
|
||||||
|
return `/volumes/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `<source>:/volumes/<key>:<mode>` helper bind string. Backup capture uses `ro`, restore `rw`. */
|
||||||
|
export function volumeBind(source: string, key: string, mode: 'ro' | 'rw'): string {
|
||||||
|
return `${source}:${volumeInclude(key)}:${mode}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single mount as returned by a container inspect (the fields we use). */
|
/** A single mount as returned by a container inspect (the fields we use). */
|
||||||
export interface Mount {
|
export interface Mount {
|
||||||
@@ -54,19 +70,6 @@ export interface DiscoveryResult {
|
|||||||
|
|
||||||
const SUPPORTED = new Set(['volume', 'bind']);
|
const SUPPORTED = new Set(['volume', 'bind']);
|
||||||
|
|
||||||
/** Make a bind Source/Destination into a safe `/volumes/` key. Non-safe chars
|
|
||||||
* become `_`; a collision gets a short numeric suffix so two mounts never map to
|
|
||||||
* the same key (which would make restic overwrite one with the other). */
|
|
||||||
export function safeKey(base: string, taken: Set<string> = new Set()): string {
|
|
||||||
let key = base.replace(/^\//, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 100) || 'bind';
|
|
||||||
if (!taken.has(key)) { taken.add(key); return key; }
|
|
||||||
let i = 2;
|
|
||||||
while (taken.has(`${key}_${i}`)) i++;
|
|
||||||
const out = `${key}_${i}`;
|
|
||||||
taken.add(out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn a list of containers' mounts into the volumes to back up. Named volumes
|
* Turn a list of containers' mounts into the volumes to back up. Named volumes
|
||||||
* key on their volume name (stable, restore knows it); binds key on a
|
* key on their volume name (stable, restore knows it); binds key on a
|
||||||
@@ -77,9 +80,8 @@ export function safeKey(base: string, taken: Set<string> = new Set()): string {
|
|||||||
export function discoverVolumesFromMounts(containers: Array<{ name: string; mounts: Mount[] }>): DiscoveryResult {
|
export function discoverVolumesFromMounts(containers: Array<{ name: string; mounts: Mount[] }>): DiscoveryResult {
|
||||||
const volumes: DiscoveredVolume[] = [];
|
const volumes: DiscoveredVolume[] = [];
|
||||||
const skipped: SkippedMount[] = [];
|
const skipped: SkippedMount[] = [];
|
||||||
const seenVolumeNames = new Set<string>();
|
const seen = new Set<string>(); // a volume shared across containers is captured once
|
||||||
const seenBindPairs = new Set<string>();
|
const takenKeys = new Set<string>(); // no two captures share a /volumes/<key>
|
||||||
const takenKeys = new Set<string>();
|
|
||||||
|
|
||||||
for (const container of containers) {
|
for (const container of containers) {
|
||||||
for (const mount of container.mounts) {
|
for (const mount of container.mounts) {
|
||||||
@@ -93,12 +95,15 @@ export function discoverVolumesFromMounts(containers: Array<{ name: string; moun
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mount.Type === 'volume' && mount.Name) {
|
if (mount.Type === 'volume' && mount.Name) {
|
||||||
if (seenVolumeNames.has(mount.Name)) continue;
|
const dedup = volumeDedupKey({ type: 'volume', source: mount.Name, destination: mount.Destination ?? '', name: mount.Name });
|
||||||
seenVolumeNames.add(mount.Name);
|
if (seen.has(dedup)) continue;
|
||||||
takenKeys.add(mount.Name);
|
seen.add(dedup);
|
||||||
|
// Named volumes key on their name; volumeStorageKey routes it through the shared
|
||||||
|
// allocator so it can't collide with a bind whose slug equals the volume name.
|
||||||
|
const key = volumeStorageKey({ type: 'volume', source: mount.Name, destination: mount.Destination ?? '', name: mount.Name }, takenKeys);
|
||||||
volumes.push({
|
volumes.push({
|
||||||
key: mount.Name,
|
key,
|
||||||
bind: `${mount.Name}:/volumes/${mount.Name}:ro`,
|
bind: volumeBind(mount.Name, key, 'ro'),
|
||||||
name: mount.Name,
|
name: mount.Name,
|
||||||
type: 'volume',
|
type: 'volume',
|
||||||
source: mount.Name,
|
source: mount.Name,
|
||||||
@@ -112,13 +117,15 @@ export function discoverVolumesFromMounts(containers: Array<{ name: string; moun
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const pairKey = `${mount.Source}\n${mount.Destination}`;
|
// Two binds to the same destination from DIFFERENT sources are different volumes
|
||||||
if (seenBindPairs.has(pairKey)) continue;
|
// and both survive (#1373); only an identical (source,destination) is deduped.
|
||||||
seenBindPairs.add(pairKey);
|
const dedup = volumeDedupKey({ type: 'bind', source: mount.Source, destination: mount.Destination });
|
||||||
const key = safeKey(mount.Destination, takenKeys);
|
if (seen.has(dedup)) continue;
|
||||||
|
seen.add(dedup);
|
||||||
|
const key = volumeStorageKey({ type: 'bind', source: mount.Source, destination: mount.Destination }, takenKeys);
|
||||||
volumes.push({
|
volumes.push({
|
||||||
key,
|
key,
|
||||||
bind: `${mount.Source}:/volumes/${key}:ro`,
|
bind: volumeBind(mount.Source, key, 'ro'),
|
||||||
name: mount.Destination,
|
name: mount.Destination,
|
||||||
type: 'bind',
|
type: 'bind',
|
||||||
source: mount.Source,
|
source: mount.Source,
|
||||||
|
|||||||
@@ -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
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { isValidCron } from '../scheduler/cron-utils';
|
import { isValidCron } from '../scheduler/cron-utils';
|
||||||
import { privateIpReason, dangerousHostReason, isSafeWebhookUrl, isSafeNotificationUrl } from '../url-safety';
|
import { privateIpReason, dangerousHostReason, isSafeWebhookUrl, isSafeNotificationUrl } from '../url-safety';
|
||||||
import { BackupError } from './models';
|
import { BackupError, isLocalRepo } from './models';
|
||||||
export { privateIpReason };
|
export { privateIpReason };
|
||||||
|
|
||||||
// Restic exits 10 when the repo isn't initialized.
|
// Restic exits 10 when the repo isn't initialized.
|
||||||
@@ -30,11 +30,98 @@ export function parseOptionsJson(options: string | null | undefined): Record<str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The job.options shape a backup run consumes (subset of what's persisted on a config). */
|
||||||
|
export interface JobOptions {
|
||||||
|
excludePatterns?: string[];
|
||||||
|
excludeCaches?: boolean;
|
||||||
|
compression?: string;
|
||||||
|
limitUpload?: number;
|
||||||
|
limitDownload?: number;
|
||||||
|
webhookSuccess?: string;
|
||||||
|
webhookFailure?: string;
|
||||||
|
excludedStackFiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a config's stored options (from parseOptionsJson) into the job.options a backup run uses.
|
||||||
|
* Pure so a SAVED backup (scheduled or "run now") gets EXACTLY the same options as an in-request
|
||||||
|
* run. Every persisted field must be forwarded here, or it silently vanishes on the config-driven path.
|
||||||
|
*/
|
||||||
|
export function buildJobOptions(opts: Record<string, any>): JobOptions {
|
||||||
|
return {
|
||||||
|
excludePatterns: typeof opts.excludePatterns === 'string'
|
||||||
|
? opts.excludePatterns.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
excludeCaches: opts.excludeCaches,
|
||||||
|
compression: opts.compression,
|
||||||
|
limitUpload: opts.limitUpload,
|
||||||
|
limitDownload: opts.limitDownload,
|
||||||
|
webhookSuccess: typeof opts.webhookSuccess === 'string' ? opts.webhookSuccess : undefined,
|
||||||
|
webhookFailure: typeof opts.webhookFailure === 'string' ? opts.webhookFailure : undefined,
|
||||||
|
excludedStackFiles: Array.isArray(opts.excludedStackFiles) ? opts.excludedStackFiles : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A destination's extra restic flags, split by scope. `backup` applies to backup + repo
|
||||||
|
* ops; `restore` applies only to `restic restore`. */
|
||||||
|
export interface BackupFlags {
|
||||||
|
backup: string;
|
||||||
|
restore: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the stored `flags` column into typed {backup, restore}. The column is a JSON
|
||||||
|
* object `{"backup":"...","restore":"..."}`. A LEGACY value (a bare flag string like
|
||||||
|
* `--limit-upload 5120`, or malformed JSON) is treated as BACKUP flags with an empty
|
||||||
|
* restore string. SINGLE place that string-vs-JSON is decided, so the ambiguity can't
|
||||||
|
* leak elsewhere.
|
||||||
|
*/
|
||||||
|
export function parseBackupFlags(flags: string | null | undefined): BackupFlags {
|
||||||
|
if (!flags || !flags.trim()) return { backup: '', restore: '' };
|
||||||
|
const trimmed = flags.trim();
|
||||||
|
// Only a JSON OBJECT counts as the new shape; anything else is a legacy flag string.
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(trimmed);
|
||||||
|
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||||
|
return {
|
||||||
|
backup: typeof o.backup === 'string' ? o.backup : '',
|
||||||
|
restore: typeof o.restore === 'string' ? o.restore : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch { /* fall through to legacy */ }
|
||||||
|
}
|
||||||
|
return { backup: trimmed, restore: '' }; // legacy bare string = backup flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize typed {backup, restore} back into the JSON stored in the `flags` column.
|
||||||
|
* Returns null when both are empty (keeps the column NULL rather than storing `{}`). */
|
||||||
|
export function serializeBackupFlags(f: BackupFlags): string | null {
|
||||||
|
const backup = (f.backup ?? '').trim();
|
||||||
|
const restore = (f.restore ?? '').trim();
|
||||||
|
if (!backup && !restore) return null;
|
||||||
|
return JSON.stringify({ backup, restore });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a stored `selected_volumes` JSON blob into a `string[]` or null. Guards the SHAPE,
|
||||||
|
* not just the JSON: a corrupt/legacy row holding a string (or other non-array) must NOT
|
||||||
|
* flow into `new Set(...)` as an iterable-of-chars, which would match no volume key and
|
||||||
|
* silently filter EVERY volume out (a metadata-only snapshot reported as success). Any
|
||||||
|
* non-array parses to null = "all volumes" (the safe default).
|
||||||
|
*/
|
||||||
|
export function parseSelectedVolumes(raw: string | null | undefined): string[] | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
let parsed: unknown;
|
||||||
|
try { parsed = JSON.parse(raw); } catch { return null; }
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed.filter((x): x is string => typeof x === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a stored destination `policies` JSON blob. Bad/missing JSON → empty
|
* Parse a stored destination `policies` JSON blob. Bad/missing JSON → empty
|
||||||
* object, logging ONCE so a corrupt policies field is diagnosable rather than
|
* object, logging ONCE so a corrupt policies field is diagnosable rather than
|
||||||
* silently disabling autoUnlock/prune/check/verify. This is the single source of
|
* silently disabling autoUnlock/prune/check/verify.
|
||||||
* truth for the previously-duplicated inline IIFEs.
|
|
||||||
*/
|
*/
|
||||||
export function parsePoliciesJson(policies: string | null | undefined): Record<string, any> {
|
export function parsePoliciesJson(policies: string | null | undefined): Record<string, any> {
|
||||||
if (!policies) return {};
|
if (!policies) return {};
|
||||||
@@ -87,7 +174,7 @@ export const ALLOWED_REPO_SCHEMES = ['rest:', 's3:', 'b2:', 'azure:', 'gs:'] as
|
|||||||
export function isAllowedRepository(repository: string | null | undefined): boolean {
|
export function isAllowedRepository(repository: string | null | undefined): boolean {
|
||||||
if (!repository || typeof repository !== 'string') return false;
|
if (!repository || typeof repository !== 'string') return false;
|
||||||
const repo = repository.trim();
|
const repo = repository.trim();
|
||||||
if (repo.startsWith('/')) return true; // local absolute path
|
if (isLocalRepo(repo)) return true;
|
||||||
return ALLOWED_REPO_SCHEMES.some((s) => repo.startsWith(s));
|
return ALLOWED_REPO_SCHEMES.some((s) => repo.startsWith(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +201,7 @@ export function sanitizeWebhookUrlForLog(raw: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire a per-config backup webhook with a JSON payload. Best-effort and
|
* Fire a per-config backup webhook with a JSON payload. Best-effort and
|
||||||
* fire-and-forget: a webhook never changes the backup outcome. Ported from the
|
* fire-and-forget: a webhook never changes the backup outcome. Three hardening layers:
|
||||||
* legacy engine, keeping its three hardening layers:
|
|
||||||
* - SSRF literal-host guard (isSafeWebhookUrl): block loopback/private/metadata.
|
* - SSRF literal-host guard (isSafeWebhookUrl): block loopback/private/metadata.
|
||||||
* - DNS-rebinding guard: resolve the hostname and re-check EVERY resolved IP
|
* - DNS-rebinding guard: resolve the hostname and re-check EVERY resolved IP
|
||||||
* against the private/metadata ranges before connecting, so a public-looking
|
* against the private/metadata ranges before connecting, so a public-looking
|
||||||
@@ -234,75 +320,14 @@ export function isValidSnapshotId(id: string): boolean {
|
|||||||
* the human message, with the surrounding prefix preserved. Also strips ANSI
|
* the human message, with the surrounding prefix preserved. Also strips ANSI
|
||||||
* colour escapes that restic sometimes emits when stderr is a TTY (the helper
|
* colour escapes that restic sometimes emits when stderr is a TTY (the helper
|
||||||
* container's stderr looks like a TTY to restic). */
|
* container's stderr looks like a TTY to restic). */
|
||||||
export function cleanErrorMsg(msg: string): string {
|
// cleanErrorMsg lives in error-message.ts; re-exported so importers of helpers.ts keep working.
|
||||||
// Strip ANSI escape codes
|
export { cleanErrorMsg } from './error-message';
|
||||||
const clean = msg.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
||||||
// Try parsing the whole string as JSON
|
|
||||||
try { const p = JSON.parse(clean); if (p.message) return p.message; } catch { /* not pure JSON */ }
|
|
||||||
// Try extracting embedded JSON — replace real newlines so JSON.parse works
|
|
||||||
const jsonStart = clean.indexOf('{');
|
|
||||||
const jsonEnd = clean.lastIndexOf('}');
|
|
||||||
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
|
||||||
try {
|
|
||||||
const jsonStr = clean.slice(jsonStart, jsonEnd + 1).replace(/\n/g, '\\n');
|
|
||||||
const parsed = JSON.parse(jsonStr);
|
|
||||||
if (parsed.message) {
|
|
||||||
const prefix = clean.slice(0, jsonStart).trim();
|
|
||||||
const message = parsed.message.replace(/\\n/g, ' ').trim();
|
|
||||||
return prefix ? `${prefix} ${message}` : message;
|
|
||||||
}
|
|
||||||
if (parsed.message_type) {
|
|
||||||
// A killed helper appends restic's last --json PROGRESS line
|
|
||||||
// (message_type, no `message`) to the exit-code detail — no error
|
|
||||||
// text; drop it, keep just the readable prefix.
|
|
||||||
const p2 = clean.slice(0, jsonStart).trim().replace(/:\s*$/, '');
|
|
||||||
if (p2) return p2;
|
|
||||||
}
|
|
||||||
} catch { /* not embedded JSON */ }
|
|
||||||
}
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspect `restic forget --dry-run --json` output and decide whether running
|
* The delete-everything guard for `restic forget --dry-run --json`. Re-exported from retention.ts
|
||||||
* the real `forget --prune` would delete every snapshot in the targeted group.
|
* (`checkWouldWipe`) so this data-loss guard lives in exactly one place - a second copy could drift.
|
||||||
*
|
|
||||||
* Restic's JSON shape for `forget` is an array of "forget groups", each with
|
|
||||||
* `keep`, `remove`, and `reasons`. Snapshots are objects with an `id` field;
|
|
||||||
* we count list lengths rather than poking at fields. When the policy keeps
|
|
||||||
* zero and removes everything, that's the dangerous case we want to block.
|
|
||||||
*
|
|
||||||
* Empty/unparseable stdout → `{ wouldWipe: false, ... zeros }`. Restic emits
|
|
||||||
* an empty array when the repo holds no snapshots, which is also "not a
|
|
||||||
* wipe-out worth blocking" because there's nothing to lose.
|
|
||||||
*
|
|
||||||
* Pure function, no I/O — the caller runs it after running restic; tests can
|
|
||||||
* pass canned stdout.
|
|
||||||
*/
|
*/
|
||||||
export function wouldDeleteAllSnapshots(stdout: string): {
|
export { checkWouldWipe as wouldDeleteAllSnapshots } from './retention';
|
||||||
wouldWipe: boolean;
|
|
||||||
keep: number;
|
|
||||||
remove: number;
|
|
||||||
total: number;
|
|
||||||
} {
|
|
||||||
let keep = 0;
|
|
||||||
let remove = 0;
|
|
||||||
// Restic forget --json emits a single JSON document (array of groups).
|
|
||||||
// Parse the whole stdout once; ignore trailing whitespace.
|
|
||||||
const trimmed = stdout.trim();
|
|
||||||
if (!trimmed) return { wouldWipe: false, keep: 0, remove: 0, total: 0 };
|
|
||||||
let parsed: unknown;
|
|
||||||
try { parsed = JSON.parse(trimmed); } catch { return { wouldWipe: false, keep: 0, remove: 0, total: 0 }; }
|
|
||||||
if (!Array.isArray(parsed)) return { wouldWipe: false, keep: 0, remove: 0, total: 0 };
|
|
||||||
for (const group of parsed) {
|
|
||||||
if (!group || typeof group !== 'object') continue;
|
|
||||||
const g = group as { keep?: unknown[]; remove?: unknown[] };
|
|
||||||
if (Array.isArray(g.keep)) keep += g.keep.length;
|
|
||||||
if (Array.isArray(g.remove)) remove += g.remove.length;
|
|
||||||
}
|
|
||||||
const total = keep + remove;
|
|
||||||
return { wouldWipe: total > 0 && keep === 0 && remove > 0, keep, remove, total };
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// restic subprocess hardening
|
// restic subprocess hardening
|
||||||
@@ -356,10 +381,8 @@ export function filterCloudEnvVars(envVars: Record<string, string>): Record<stri
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF URL-safety primitives now live in the neutral $lib/server/url-safety
|
// SSRF URL-safety primitives live in the neutral $lib/server/url-safety module
|
||||||
// module so subsystems (backups, notifications) import DOWN into a shared home
|
// (shared by backups + notifications). Re-exported here so backup-side importers keep resolving.
|
||||||
// rather than the notification router importing UP from the backup engine.
|
|
||||||
// Re-exported here so existing backup-side importers keep resolving unchanged.
|
|
||||||
export { isSafeWebhookUrl };
|
export { isSafeWebhookUrl };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,56 +407,67 @@ export function buildResticEnv(
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
// restic global/backup/restore flags that are safe for an operator to append
|
// Restic flags a destination operator may append. Backup/global flags apply to backup +
|
||||||
// per destination. Anything that can read/write arbitrary files or run code
|
// repo ops; restore flags apply ONLY to `restic restore` (which has its own flags like
|
||||||
// (--option, --password-command, --cacert to an arbitrary path, etc.) is
|
// --exclude-xattr, #1349). The two are stored SEPARATELY (see BackupFlags), so a
|
||||||
// rejected. Flags may be `--flag` or `--flag=value`.
|
// restore-only flag can never leak into `restic backup`. Anything that can read/write
|
||||||
const RESTIC_FLAG_ALLOW = new Set([
|
// arbitrary files or run code (--option, --password-command, --cacert to an arbitrary
|
||||||
|
// path) is rejected. Flags may be `--flag` or `--flag=value`.
|
||||||
|
const RESTIC_BACKUP_FLAG_ALLOW = new Set([
|
||||||
'--limit-upload', '--limit-download', '--no-cache', '--cleanup-cache',
|
'--limit-upload', '--limit-download', '--no-cache', '--cleanup-cache',
|
||||||
'--pack-size', '--compression', '--no-lock', '--json', '--quiet', '--verbose',
|
'--pack-size', '--compression', '--no-lock', '--json', '--quiet', '--verbose',
|
||||||
'--tls-client-cert', '--retry-lock', '--insecure-tls'
|
'--tls-client-cert', '--retry-lock', '--insecure-tls'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Restore adds --exclude-xattr (restore-only in restic; #1349 SELinux xattr the
|
||||||
|
// unprivileged helper can't remove on a cross-distro restore).
|
||||||
|
const RESTIC_RESTORE_FLAG_ALLOW = new Set([...RESTIC_BACKUP_FLAG_ALLOW, '--exclude-xattr']);
|
||||||
|
|
||||||
// Allowlisted flags that take a VALUE. Written joined (`--retry-lock=10m`) or
|
// Allowlisted flags that take a VALUE. Written joined (`--retry-lock=10m`) or
|
||||||
// space-separated (`--retry-lock 10m`); in the space-separated form the FOLLOWING
|
// space-separated (`--retry-lock 10m`); in the space-separated form the FOLLOWING
|
||||||
// token is that value, not a flag, so it legitimately doesn't start with `--`.
|
// token is that value, not a flag, so it legitimately doesn't start with `--`.
|
||||||
const RESTIC_FLAG_TAKES_VALUE = new Set([
|
const RESTIC_FLAG_TAKES_VALUE = new Set([
|
||||||
'--limit-upload', '--limit-download', '--pack-size', '--compression',
|
'--limit-upload', '--limit-download', '--pack-size', '--compression',
|
||||||
'--tls-client-cert', '--retry-lock'
|
'--tls-client-cert', '--retry-lock', '--exclude-xattr'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/** Core allowlist validator, parameterized by which allowlist applies. */
|
||||||
* Split and validate a destination's extra restic CLI flags against an
|
function sanitizeAgainst(flags: string | null | undefined, allow: Set<string>): string[] {
|
||||||
* allowlist. Returns the accepted flag tokens; throws on any flag not in the
|
|
||||||
* allowlist so we never forward attacker-controlled options like `--option`,
|
|
||||||
* `--password-command`, or a bare non-flag argument. A value that follows a
|
|
||||||
* space-separated value-taking flag (e.g. the `10m` in `--retry-lock 10m`) is
|
|
||||||
* accepted as that flag's argument rather than treated as a flag itself.
|
|
||||||
*/
|
|
||||||
export function sanitizeResticFlags(flags: string | null | undefined): string[] {
|
|
||||||
if (!flags || !flags.trim()) return [];
|
if (!flags || !flags.trim()) return [];
|
||||||
const tokens = flags.trim().split(/\s+/);
|
const tokens = flags.trim().split(/\s+/);
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let expectValue = false;
|
let expectValue = false;
|
||||||
for (const tok of tokens) {
|
for (const tok of tokens) {
|
||||||
if (expectValue) {
|
if (expectValue) {
|
||||||
// This token is the value of the previous value-taking flag.
|
out.push(tok); // value of the previous space-separated value-taking flag
|
||||||
out.push(tok);
|
|
||||||
expectValue = false;
|
expectValue = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const name = tok.startsWith('--') ? tok.split('=')[0] : tok;
|
const name = tok.startsWith('--') ? tok.split('=')[0] : tok;
|
||||||
if (!name.startsWith('--') || !RESTIC_FLAG_ALLOW.has(name)) {
|
if (!name.startsWith('--') || !allow.has(name)) {
|
||||||
throw new Error(`Disallowed restic flag: ${tok}`);
|
throw new Error(`Disallowed restic flag: ${tok}`);
|
||||||
}
|
}
|
||||||
out.push(tok);
|
out.push(tok);
|
||||||
// A space-separated value-taking flag (no `=value`) consumes the next token.
|
|
||||||
if (RESTIC_FLAG_TAKES_VALUE.has(name) && !tok.includes('=')) expectValue = true;
|
if (RESTIC_FLAG_TAKES_VALUE.has(name) && !tok.includes('=')) expectValue = true;
|
||||||
}
|
}
|
||||||
if (expectValue) throw new Error('Restic flag is missing its value');
|
if (expectValue) throw new Error('Restic flag is missing its value');
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split and validate a destination's BACKUP/global extra restic flags against the backup
|
||||||
|
* allowlist. Throws on any flag not allowed (never forwards --option/--password-command/etc).
|
||||||
|
*/
|
||||||
|
export function sanitizeResticFlags(flags: string | null | undefined): string[] {
|
||||||
|
return sanitizeAgainst(flags, RESTIC_BACKUP_FLAG_ALLOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split and validate a destination's RESTORE extra restic flags (backup allowlist plus
|
||||||
|
* restore-only flags like --exclude-xattr). */
|
||||||
|
export function sanitizeRestoreFlags(flags: string | null | undefined): string[] {
|
||||||
|
return sanitizeAgainst(flags, RESTIC_RESTORE_FLAG_ALLOW);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conservative default retention for SCHEDULED configs created without a policy —
|
* Conservative default retention for SCHEDULED configs created without a policy —
|
||||||
* otherwise their snapshots grow forever, since pruning is gated on a non-empty
|
* otherwise their snapshots grow forever, since pruning is gated on a non-empty
|
||||||
@@ -454,21 +488,11 @@ export function retentionToStore(retention: any, schedule: unknown): string | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide the `enabled` flag when a backup config is updated, auto-enabling a config
|
* Decide the `enabled` flag when a backup config is updated. A "run once" backup persists a
|
||||||
* that gains a schedule.
|
* MANUAL, paused config (schedule=null, enabled=false); adding a cron schedule later should make
|
||||||
*
|
* it actually run, so a manual -> scheduled transition auto-enables it. A config that was ALREADY
|
||||||
* The motivating flow: a "run once" backup persists a MANUAL, paused config
|
* scheduled is left alone, so a deliberately-paused scheduled backup stays paused across edits.
|
||||||
* (schedule=null, enabled=false) — correct on its own. But when the user later edits
|
* Applies to container and stack backups alike (both go through PUT).
|
||||||
* that config to ADD a cron schedule, they expect it to actually run; leaving it
|
|
||||||
* paused (because the run-once config was created disabled and the UI's Enabled
|
|
||||||
* toggle still reflected that) is a surprise that forces a manual un-pause on the
|
|
||||||
* Schedules page. So: a transition from manual (no schedule) to scheduled (a real
|
|
||||||
* cron) auto-enables the config.
|
|
||||||
*
|
|
||||||
* This deliberately does NOT touch a config that was ALREADY scheduled — a user who
|
|
||||||
* paused a scheduled backup on purpose keeps it paused across edits. Same rationale
|
|
||||||
* as retentionToStore: adding a schedule should leave the config in a sane, working
|
|
||||||
* state. Applies identically to container and stack backups (both go through PUT).
|
|
||||||
*/
|
*/
|
||||||
export function resolveEnabledOnScheduleChange(input: {
|
export function resolveEnabledOnScheduleChange(input: {
|
||||||
requestedEnabled: unknown;
|
requestedEnabled: unknown;
|
||||||
@@ -512,8 +536,24 @@ export function validateRepositoryForSave(repository: string): string | null {
|
|||||||
* throws on a disallowed flag; surface that as an error string, or null if ok. */
|
* throws on a disallowed flag; surface that as an error string, or null if ok. */
|
||||||
export function validateFlags(flags: unknown): string | null {
|
export function validateFlags(flags: unknown): string | null {
|
||||||
if (flags === undefined || flags === null) return null;
|
if (flags === undefined || flags === null) return null;
|
||||||
try { sanitizeResticFlags(flags as string); return null; }
|
// The route may send either the split shape ({ backup, restore }) or a legacy bare
|
||||||
catch (e) { return e instanceof Error ? e.message : 'Invalid restic flags'; }
|
// string. Validate each scope against its own allowlist (restore permits --exclude-xattr).
|
||||||
|
const { backup, restore } = parseBackupFlags(typeof flags === 'string' ? flags : serializeBackupFlags(flags as BackupFlags) ?? '');
|
||||||
|
try {
|
||||||
|
sanitizeResticFlags(backup);
|
||||||
|
sanitizeRestoreFlags(restore);
|
||||||
|
return null;
|
||||||
|
} catch (e) { return e instanceof Error ? e.message : 'Invalid restic flags'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a raw {backup, restore} pair from the API and return the serialized flags
|
||||||
|
* column value (JSON, or null when both empty), or throw with the allowlist error. */
|
||||||
|
export function validateAndSerializeFlags(backup: unknown, restore: unknown): string | null {
|
||||||
|
const b = typeof backup === 'string' ? backup : '';
|
||||||
|
const r = typeof restore === 'string' ? restore : '';
|
||||||
|
sanitizeResticFlags(b); // throws on a disallowed backup flag
|
||||||
|
sanitizeRestoreFlags(r); // throws on a disallowed restore flag
|
||||||
|
return serializeBackupFlags({ backup: b, restore: r });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,9 +31,18 @@
|
|||||||
* different keys, fail to collide, and run concurrently — a torn snapshot
|
* different keys, fail to collide, and run concurrently — a torn snapshot
|
||||||
* reported as success.
|
* reported as success.
|
||||||
*/
|
*/
|
||||||
export function liveTargetKey(envId: number | null | undefined, dataPaths: string[]): string {
|
export function liveTargetKey(envId: number | null | undefined, dataPaths: string[], fallbackIdentity?: string): string {
|
||||||
const env = envId ?? 'local';
|
const env = envId ?? 'local';
|
||||||
const sources = dataPaths.map(bindSource).filter(Boolean).sort();
|
const sources = dataPaths.map(bindSource).filter(Boolean).sort();
|
||||||
|
// Config-only targets (no named volumes, no binds) have NO data sources, so keying on
|
||||||
|
// sources alone makes every such target on the same env collide (`${env}::`), and the
|
||||||
|
// REJECT lock silently skips all but one - two disjoint config-only backups in one cron
|
||||||
|
// window lose one every run. Fall back to a per-target identity ONLY when there are no
|
||||||
|
// sources, so disjoint config-only targets don't alias. Targets that DO share data still
|
||||||
|
// key on the data identity (a backup:ro and restore:rw of the same volume must collide).
|
||||||
|
if (sources.length === 0 && fallbackIdentity) {
|
||||||
|
return `${env}::config:${fallbackIdentity}`;
|
||||||
|
}
|
||||||
return `${env}::${sources.join('|')}`;
|
return `${env}::${sources.join('|')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ export type OperationStatus =
|
|||||||
| 'success'
|
| 'success'
|
||||||
| 'warning'
|
| 'warning'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'cancelled'
|
| 'cancelled';
|
||||||
| 'stale';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closed error taxonomy. The UI shows a human message; automation branches on
|
* Closed error taxonomy. The UI shows a human message; automation branches on
|
||||||
@@ -226,6 +225,7 @@ export function parseSnapshots(stdout: string): Snapshot[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Snapshot tags — STABLE IDs, never normalized names
|
// Snapshot tags — STABLE IDs, never normalized names
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -235,10 +235,9 @@ export function parseSnapshots(stdout: string): Snapshot[] {
|
|||||||
* ownership, env-access) use ONLY the stable-id tags; the name/type tags are
|
* ownership, env-access) use ONLY the stable-id tags; the name/type tags are
|
||||||
* decoration for humans browsing with the restic CLI.
|
* decoration for humans browsing with the restic CLI.
|
||||||
*
|
*
|
||||||
* Why stable ids: the old code tagged `dockhand:config=<name>-<type>-<env>`
|
* Why stable ids, not names: a normalized env name collides when two env names normalize to the
|
||||||
* built from a NORMALIZED env name, which collides when two env names normalize
|
* same string, which would cross-prune retention and resolve env-access to the wrong environment.
|
||||||
* to the same string — causing retention to cross-prune and env-access to
|
* Ids never collide.
|
||||||
* resolve to the wrong environment. Ids never collide.
|
|
||||||
*/
|
*/
|
||||||
export type BackupTargetType = 'container' | 'stack';
|
export type BackupTargetType = 'container' | 'stack';
|
||||||
|
|
||||||
@@ -308,3 +307,12 @@ export const TIMEOUTS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TimeoutTier = keyof typeof TIMEOUTS;
|
export type TimeoutTier = keyof typeof TIMEOUTS;
|
||||||
|
|
||||||
|
/** The SINGLE definition of "this restic repository is a local filesystem path" (vs a
|
||||||
|
* rest:/s3:/... scheme URL). Local = an ABSOLUTE path, the only form the save gate
|
||||||
|
* (isAllowedRepository) accepts. Every site that binds/guards/chowns the local repo MUST
|
||||||
|
* use this so they can't diverge - a `./`-vs-`/` mismatch would guard a path the helper
|
||||||
|
* never mounts, hard-failing a valid repo with a misleading "repository not found". */
|
||||||
|
export function isLocalRepo(repository: string): boolean {
|
||||||
|
return repository.startsWith('/');
|
||||||
|
}
|
||||||
@@ -7,18 +7,17 @@
|
|||||||
* A record opens as `running`, streams throttled progress + log lines, and
|
* A record opens as `running`, streams throttled progress + log lines, and
|
||||||
* closes exactly once with a terminal status DERIVED IN ONE PLACE (see
|
* closes exactly once with a terminal status DERIVED IN ONE PLACE (see
|
||||||
* ./operations-core). The startup scrub rewrites any row left `running` by a
|
* ./operations-core). The startup scrub rewrites any row left `running` by a
|
||||||
* dead process to `stale`, so an interrupted operation is always resolvable
|
* dead process to `failed`, so an interrupted operation is always resolvable
|
||||||
* without a transaction — "running at boot" is unambiguously not-completed.
|
* without a transaction — "running at boot" is unambiguously not-completed.
|
||||||
*
|
*
|
||||||
* The pure decision logic (terminal-status derivation, progress throttle, scrub
|
* The pure decision logic (terminal-status derivation, progress throttle) lives
|
||||||
* rule) lives in ./operations-core and is unit-tested there; this module is the
|
* in ./operations-core and is unit-tested there; this module is the thin
|
||||||
* thin DB-bound wrapper.
|
* DB-bound wrapper.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
createScheduleExecution,
|
createScheduleExecution,
|
||||||
updateScheduleExecution,
|
updateScheduleExecution,
|
||||||
appendScheduleExecutionLog,
|
appendScheduleExecutionLog,
|
||||||
failStaleRunningExecutions,
|
|
||||||
} from '../db';
|
} from '../db';
|
||||||
import type { OperationKind } from './models';
|
import type { OperationKind } from './models';
|
||||||
import {
|
import {
|
||||||
@@ -91,7 +90,7 @@ export class Operation {
|
|||||||
* metadata/verifying/pruning, the volume list, errors/warnings) and is ALWAYS
|
* metadata/verifying/pruning, the volume list, errors/warnings) and is ALWAYS
|
||||||
* persisted — otherwise those lines drop out of the execution log
|
* persisted — otherwise those lines drop out of the execution log
|
||||||
* non-deterministically when they land in a throttle window occupied by restic
|
* non-deterministically when they land in a throttle window occupied by restic
|
||||||
* spam. (That was a real bug: stage markers vanished from the log under load.)
|
* spam.
|
||||||
*/
|
*/
|
||||||
progress(status: string, message: string, detail?: unknown): void {
|
progress(status: string, message: string, detail?: unknown): void {
|
||||||
if (this.onProgressCb) this.onProgressCb(status, message, detail);
|
if (this.onProgressCb) this.onProgressCb(status, message, detail);
|
||||||
@@ -138,14 +137,3 @@ export class Operation {
|
|||||||
export function openOperation(opts: OpenOptions, onProgress?: (status: string, message: string, detail?: unknown) => void): Promise<Operation> {
|
export function openOperation(opts: OpenOptions, onProgress?: (status: string, message: string, detail?: unknown) => void): Promise<Operation> {
|
||||||
return Operation.open(opts, onProgress);
|
return Operation.open(opts, onProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Startup scrub: mark any operation left `running`/`queued` by a dead process as
|
|
||||||
* a terminal failure so it can't linger forever and inflate "in progress"
|
|
||||||
* metrics. Delegates to the shared DB sweep (which sets them failed) — a
|
|
||||||
* follow-up may split `stale` from `failed` at the DB layer, but the invariant
|
|
||||||
* (no row stays `running` across a restart) holds today.
|
|
||||||
*/
|
|
||||||
export async function scrubInterruptedOperations(): Promise<number> {
|
|
||||||
return failStaleRunningExecutions();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Pure transport predicates for the streaming put-archive path. Import-light (no docker.ts /
|
||||||
|
* db deps) so it's unit-testable — this is the edge-cap SAFEGUARD the whole streaming design
|
||||||
|
* leans on: if this wrongly reported an edge env as streamable, the uncapped stream path would
|
||||||
|
* run and the buffered+capped fallback would never engage (OOM risk).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PassThrough, type Readable } from 'node:stream';
|
||||||
|
|
||||||
|
/** Minimal shape we branch on (a subset of DockerClientConfig). */
|
||||||
|
export interface TransportConfig {
|
||||||
|
connectionType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stalled tar upload has NO absolute timeout (a legit multi-GB tar can take a long time), so its
|
||||||
|
// only backstop is the helper's 6h ceiling - a half-dead agent that accepts the connection then
|
||||||
|
// stops reading would hang for hours. An IDLE (progress-based) watchdog distinguishes "slow but
|
||||||
|
// alive" from "dead": it fails only after N minutes of ZERO bytes flowing, and resets on every chunk.
|
||||||
|
export const TAR_IDLE_TIMEOUT_MS = Number(process.env.BACKUP_TAR_IDLE_MS) || 5 * 60_000;
|
||||||
|
|
||||||
|
/** Wrap a tar stream so it aborts (destroys) after TAR_IDLE_TIMEOUT_MS of zero throughput. The
|
||||||
|
* timer resets on every chunk that flows through (i.e. every chunk the receiver actually read via
|
||||||
|
* backpressure), so a stalled receiver trips it while a slow-but-progressing upload does not.
|
||||||
|
* Import-light (node:stream only) so it's unit-testable without docker.ts. */
|
||||||
|
export function withIdleWatchdog(tar: Readable, label: string, idleMs = TAR_IDLE_TIMEOUT_MS): Readable {
|
||||||
|
const out = new PassThrough();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const arm = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const err = new Error(`${label}: no data flowed for ${Math.round(idleMs / 1000)}s - the target is not reading (stalled/dead); aborting upload`);
|
||||||
|
tar.destroy(err);
|
||||||
|
out.destroy(err);
|
||||||
|
}, idleMs);
|
||||||
|
};
|
||||||
|
const disarm = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
||||||
|
// A chunk crossing the PassThrough means the receiver drained the previous one (backpressure) -
|
||||||
|
// real forward progress. Reset the idle timer on each.
|
||||||
|
out.on('data', arm);
|
||||||
|
out.on('end', disarm);
|
||||||
|
out.on('close', disarm);
|
||||||
|
tar.on('error', (e) => out.destroy(e));
|
||||||
|
arm();
|
||||||
|
return tar.pipe(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this env's transport can stream a request body with O(1) RAM. hawser-edge CANNOT
|
||||||
|
* (its WebSocket protocol base64s the whole tar into one frame); everything else (socket,
|
||||||
|
* direct, hawser-standard, and the local/undefined case) goes through node http/https core,
|
||||||
|
* which streams. Keep this the SINGLE source of the stream-vs-buffer decision. */
|
||||||
|
export function transportCanStream(config: TransportConfig): boolean {
|
||||||
|
return config.connectionType !== 'hawser-edge';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip shell/URL-dangerous chars from an archive path before it goes into the PUT query. */
|
||||||
|
export function sanitizeArchivePath(path: string): string {
|
||||||
|
return path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,17 +16,16 @@
|
|||||||
* true source for a bind key, we FAIL LOUD rather than fall back to the slug.
|
* true source for a bind key, we FAIL LOUD rather than fall back to the slug.
|
||||||
*/
|
*/
|
||||||
import { BackupError } from './models';
|
import { BackupError } from './models';
|
||||||
import { safeKey } from './discovery-core';
|
import { safeKey, volumeInclude, volumeBind } from './discovery-core';
|
||||||
|
import type { SnapshotLayout, SnapshotVolume } from './snapshot-layout';
|
||||||
|
|
||||||
/** One volume entry as stored in a snapshot's metadata.json (post-enrichment). */
|
export { volumeInclude, volumeBind };
|
||||||
export interface StoredVolume {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
source: string;
|
|
||||||
type: 'volume' | 'bind';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A docker mount as recorded in metadata.container (docker inspect Mounts[]). */
|
/** One volume entry as stored in a snapshot (the typed contract, not a parallel copy - so
|
||||||
|
* a SnapshotVolume field rename is a compile error here too, not a silent undefined). */
|
||||||
|
export type StoredVolume = SnapshotVolume;
|
||||||
|
|
||||||
|
/** A docker mount as recorded in a snapshot's container inspect (Mounts[]). */
|
||||||
interface StoredMount {
|
interface StoredMount {
|
||||||
Type?: string;
|
Type?: string;
|
||||||
Name?: string;
|
Name?: string;
|
||||||
@@ -34,10 +33,14 @@ interface StoredMount {
|
|||||||
Destination?: string;
|
Destination?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The snapshot metadata shape we read (only the fields this mapping needs). */
|
/** The snapshot metadata this mapping reads. `volumes` is intentionally `unknown`: this is
|
||||||
|
* the boundary that reads BOTH the typed SnapshotVolume[] (current) AND legacy string[]
|
||||||
|
* name/destination entries (pre-enrichment snapshots), narrowing each defensively. A
|
||||||
|
* SnapshotLayout is assignable here (its volumes: SnapshotVolume[] widens to unknown).
|
||||||
|
* `container` stays unknown (opaque inspect JSON); mountSource narrows it. */
|
||||||
export interface SnapshotMetadata {
|
export interface SnapshotMetadata {
|
||||||
volumes?: unknown;
|
volumes?: unknown;
|
||||||
container?: { Mounts?: StoredMount[] } | null;
|
container?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A `<source>:/volumes/<key>:rw` bind + its `/volumes/<key>` include. `type` and
|
/** A `<source>:/volumes/<key>:rw` bind + its `/volumes/<key>` include. `type` and
|
||||||
@@ -52,7 +55,7 @@ export interface VolumeBind {
|
|||||||
|
|
||||||
/** Build the rw bind for a resolved source + key. */
|
/** Build the rw bind for a resolved source + key. */
|
||||||
function bindFor(source: string, key: string, type: 'volume' | 'bind'): VolumeBind {
|
function bindFor(source: string, key: string, type: 'volume' | 'bind'): VolumeBind {
|
||||||
return { bind: `${source}:/volumes/${key}:rw`, include: `/volumes/${key}`, source, type };
|
return { bind: volumeBind(source, key, 'rw'), include: volumeInclude(key), source, type };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +114,8 @@ function enrichedVolume(metadata: SnapshotMetadata | null, key: string): StoredV
|
|||||||
|
|
||||||
/** Recover a source from the stored docker inspect Mounts for a key. */
|
/** Recover a source from the stored docker inspect Mounts for a key. */
|
||||||
function mountSource(metadata: SnapshotMetadata | null, key: string): { source: string; type: 'bind' | 'volume' } | null {
|
function mountSource(metadata: SnapshotMetadata | null, key: string): { source: string; type: 'bind' | 'volume' } | null {
|
||||||
const mounts = metadata?.container?.Mounts;
|
const c = metadata?.container as { Mounts?: StoredMount[] } | null | undefined;
|
||||||
|
const mounts = c?.Mounts;
|
||||||
if (!Array.isArray(mounts)) return null;
|
if (!Array.isArray(mounts)) return null;
|
||||||
for (const m of mounts) {
|
for (const m of mounts) {
|
||||||
if (m?.Type === 'volume' && m.Name && m.Name === key) return { source: m.Name, type: 'volume' };
|
if (m?.Type === 'volume' && m.Name && m.Name === key) return { source: m.Name, type: 'volume' };
|
||||||
@@ -143,7 +147,8 @@ function keyIsKnownBind(metadata: SnapshotMetadata | null, key: string): boolean
|
|||||||
if (v && typeof v === 'object' && sv.key === key && sv.type === 'bind') return true;
|
if (v && typeof v === 'object' && sv.key === key && sv.type === 'bind') return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const mounts = metadata?.container?.Mounts;
|
const c = metadata?.container as { Mounts?: StoredMount[] } | null | undefined;
|
||||||
|
const mounts = c?.Mounts;
|
||||||
if (Array.isArray(mounts)) {
|
if (Array.isArray(mounts)) {
|
||||||
for (const m of mounts) {
|
for (const m of mounts) {
|
||||||
if (m?.Type === 'bind' && m.Destination && safeKey(m.Destination) === key) return true;
|
if (m?.Type === 'bind' && m.Destination && safeKey(m.Destination) === key) return true;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* backups/retention.ts — pure retention planning.
|
* backups/retention.ts - pure retention planning.
|
||||||
*
|
*
|
||||||
* Retention decides which snapshots restic keeps and which it forgets. Two
|
* Retention decides which snapshots restic keeps and which it forgets. Two
|
||||||
* safety properties matter and both live here as pure functions:
|
* safety properties matter and both live here as pure functions:
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* that keeps nothing is always a mistake, not an intent.
|
* that keeps nothing is always a mistake, not an intent.
|
||||||
*
|
*
|
||||||
* The policy is a discriminated union so contradictory settings are
|
* The policy is a discriminated union so contradictory settings are
|
||||||
* unrepresentable — you cannot ask for "keep last 5" and "keep 6 monthly" at
|
* unrepresentable - you cannot ask for "keep last 5" and "keep 6 monthly" at
|
||||||
* once; you pick one mode.
|
* once; you pick one mode.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -38,10 +38,10 @@ function positiveInt(v: unknown): number | undefined {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a stored retention JSON blob into a RetentionPolicy. The stored shape is
|
* Parse a stored retention JSON blob into a RetentionPolicy. The stored shape is
|
||||||
* `{ keepLast?, keepDaily?, keepWeekly?, keepMonthly?, keepYearly? }` (legacy
|
* `{ keepLast?, keepDaily?, keepWeekly?, keepMonthly?, keepYearly? }`. Absent /
|
||||||
* flat form). Absent / all-zero / unparseable → `none` (the safe default), so a
|
* all-zero / unparseable resolves to `none` (the safe default), so a broken config
|
||||||
* broken config never prunes. `keepLast` takes precedence over time buckets if
|
* never prunes. `keepLast` takes precedence over time buckets if both are somehow
|
||||||
* both are somehow present (the UI only sets one).
|
* present (the UI only sets one).
|
||||||
*/
|
*/
|
||||||
export function parseRetention(stored: string | null | undefined | Record<string, unknown>): RetentionPolicy {
|
export function parseRetention(stored: string | null | undefined | Record<string, unknown>): RetentionPolicy {
|
||||||
let obj: Record<string, unknown>;
|
let obj: Record<string, unknown>;
|
||||||
@@ -77,7 +77,13 @@ export function retentionActive(policy: RetentionPolicy): boolean {
|
|||||||
* snapshots by tag. Returns null for `none` (nothing to do). The tag filter uses
|
* snapshots by tag. Returns null for `none` (nothing to do). The tag filter uses
|
||||||
* the STABLE per-config identity so no other config's snapshots enter the pool.
|
* the STABLE per-config identity so no other config's snapshots enter the pool.
|
||||||
* `--group-by ''` because helper hostnames are random and would otherwise split
|
* `--group-by ''` because helper hostnames are random and would otherwise split
|
||||||
* the group; `--prune` reclaims space; `dryRun` adds `--dry-run` for the wipe check.
|
* the group; `dryRun` adds `--dry-run` for the wipe check.
|
||||||
|
*
|
||||||
|
* NO `--prune` here: post-backup retention only removes snapshot references (fast,
|
||||||
|
* metadata-only). `--prune` walks and repacks the whole repo (minutes to hours on a
|
||||||
|
* large or slow-backend repo), so it belongs in the destination's own prune schedule
|
||||||
|
* (default monthly), not inline on every backup. Same split as restic front-ends
|
||||||
|
* (backrest, zerobyte).
|
||||||
*
|
*
|
||||||
* @param configTagFilter e.g. `dockhand:instance=<uuid>,dockhand:configid=<id>`
|
* @param configTagFilter e.g. `dockhand:instance=<uuid>,dockhand:configid=<id>`
|
||||||
*/
|
*/
|
||||||
@@ -91,18 +97,17 @@ export function buildForgetArgs(
|
|||||||
const args = ['forget'];
|
const args = ['forget'];
|
||||||
if (opts.json) args.push('--json');
|
if (opts.json) args.push('--json');
|
||||||
if (opts.dryRun) args.push('--dry-run');
|
if (opts.dryRun) args.push('--dry-run');
|
||||||
else args.push('--prune');
|
|
||||||
// `forget` needs an EXCLUSIVE repo lock; --retry-lock is how long it waits for a
|
// `forget` needs an EXCLUSIVE repo lock; --retry-lock is how long it waits for a
|
||||||
// held lock before giving up. This runs inside Dockhand's per-repo serializer, so
|
// held lock before giving up. This runs inside Dockhand's per-repo serializer, so
|
||||||
// no OTHER Dockhand op in THIS instance holds the lock — the only lock that can be
|
// no OTHER Dockhand op in THIS instance holds the lock - the only lock that can be
|
||||||
// present is either (a) an ORPHAN from a backup helper that Dockhand force-removed
|
// present is either (a) an ORPHAN from a backup helper that Dockhand force-removed
|
||||||
// mid-lock (its hostname is the dead container id, so restic won't age it out as
|
// mid-lock (its hostname is the dead container id, so restic won't age it out as
|
||||||
// stale for 30min), or (b) a live lock from a SEPARATE Dockhand instance sharing
|
// stale for 30min), or (b) a live lock from a SEPARATE Dockhand instance sharing
|
||||||
// the repo. In both cases a LONG wait is wrong: for (a) we'd block ~10min (5m ×
|
// the repo. In both cases a LONG wait is wrong: for (a) we'd block ~10min (5m x
|
||||||
// dry-run + 5m × real) on a dead owner — the actual cause of the 300s CI backup
|
// dry-run + 5m x real) on a dead owner - the actual cause of the 300s CI backup
|
||||||
// timeouts; for (b) a short wait just means retention is skipped this run (prune
|
// timeouts; for (b) a short wait just means retention is skipped this run (forget
|
||||||
// is non-fatal — the backup already succeeded — and it retries next schedule).
|
// is non-fatal - the backup already succeeded - and it retries next schedule).
|
||||||
// So keep it short. A failed/timed-out prune becomes a WARNING, never fails the
|
// So keep it short. A failed/timed-out forget becomes a WARNING, never fails the
|
||||||
// backup, and never touches another instance's lock. Overridable for slow links.
|
// backup, and never touches another instance's lock. Overridable for slow links.
|
||||||
const retryLock = process.env.RESTIC_FORGET_RETRY_LOCK || '1m';
|
const retryLock = process.env.RESTIC_FORGET_RETRY_LOCK || '1m';
|
||||||
args.push('--retry-lock', retryLock, '--group-by', '', '--tag', configTagFilter);
|
args.push('--retry-lock', retryLock, '--group-by', '', '--tag', configTagFilter);
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* backups/security.ts — the security surface for the backup/restore rewrite.
|
* backups/security.ts - the security surface for backup/restore.
|
||||||
*
|
*
|
||||||
* These are the hardened, well-tested primitives that keep restic invocation,
|
* Hardened, well-tested primitives that keep restic invocation, credential
|
||||||
* credential handling, and restore targets safe (env/flag/cloud allowlists,
|
* handling, and restore targets safe (env/flag/cloud allowlists, SSRF guards,
|
||||||
* SSRF guards, at-rest encryption, path containment). We RE-EXPORT them from
|
* at-rest encryption, path containment). RE-EXPORTED from their existing homes,
|
||||||
* their existing homes rather than re-deriving them: re-implementing a security
|
* not re-derived: one import site for the whole domain, so a reviewer sees the
|
||||||
* allowlist from scratch is exactly where a rewrite reintroduces the bug the
|
* security surface in one place.
|
||||||
* allowlist exists to prevent. One import site for the whole domain, so a
|
|
||||||
* reviewer sees the security surface in one place.
|
|
||||||
*
|
*
|
||||||
* Nothing here has app-specific logic — it is a barrel plus two small pure guards
|
* Nothing here has app-specific logic — it is a barrel plus two small pure guards
|
||||||
* the rewrite needs (restore-target containment + a snapshot-path allowlist).
|
* the rewrite needs (restore-target containment + a snapshot-path allowlist).
|
||||||
@@ -25,8 +23,10 @@ export {
|
|||||||
filterCloudEnvVars,
|
filterCloudEnvVars,
|
||||||
/** Split + validate a destination's extra restic flags against an allowlist;
|
/** Split + validate a destination's extra restic flags against an allowlist;
|
||||||
* throws on anything that could read/write arbitrary files or run code
|
* throws on anything that could read/write arbitrary files or run code
|
||||||
* (`--option`, `--password-command`, `--cacert`, …). */
|
* (`--option`, `--password-command`, `--cacert`, …). Backup/global vs restore
|
||||||
|
* (restore adds --exclude-xattr). */
|
||||||
sanitizeResticFlags,
|
sanitizeResticFlags,
|
||||||
|
sanitizeRestoreFlags,
|
||||||
/** True iff `id` is a syntactically valid restic snapshot id (hex, 8 or 64).
|
/** True iff `id` is a syntactically valid restic snapshot id (hex, 8 or 64).
|
||||||
* The first gate before an id is interpolated into any restic argv. */
|
* The first gate before an id is interpolated into any restic argv. */
|
||||||
isValidSnapshotId,
|
isValidSnapshotId,
|
||||||
@@ -92,15 +92,13 @@ export function timingSafeStrEqual(a: string, b: string): boolean {
|
|||||||
* that isn't a plain `/volumes/<[A-Za-z0-9._-]+>` before it reaches the script.
|
* that isn't a plain `/volumes/<[A-Za-z0-9._-]+>` before it reaches the script.
|
||||||
*/
|
*/
|
||||||
const SAFE_VOLUME_INCLUDE = /^\/volumes\/[A-Za-z0-9._-]+$/;
|
const SAFE_VOLUME_INCLUDE = /^\/volumes\/[A-Za-z0-9._-]+$/;
|
||||||
// A stack new-location restore also extracts the stored compose files under
|
|
||||||
// /metadata/stacks/<name>; allow that path with the same strict name char class.
|
|
||||||
const SAFE_STACK_INCLUDE = /^\/metadata\/stacks\/[A-Za-z0-9._-]+$/;
|
|
||||||
|
|
||||||
export function isSafeVolumeInclude(inc: string): boolean {
|
export function isSafeVolumeInclude(inc: string): boolean {
|
||||||
// A config-only (metadata-only) snapshot has no volumes; its new-location
|
// A config-only (metadata-only) snapshot has no volumes; its new-location
|
||||||
// restore extracts the whole stored /metadata dir. This is a fixed literal
|
// restore extracts the whole stored /metadata dir. This is a fixed literal
|
||||||
// (never attacker-influenced), so allow it explicitly.
|
// (never attacker-influenced), so allow it explicitly. The stack dir now rides
|
||||||
return inc === '/metadata' || SAFE_VOLUME_INCLUDE.test(inc) || SAFE_STACK_INCLUDE.test(inc);
|
// /volumes/__dockhand_stackdir__ (a SAFE_VOLUME_INCLUDE), so no /metadata/stacks path.
|
||||||
|
return inc === '/metadata' || SAFE_VOLUME_INCLUDE.test(inc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throwing variant — use at the boundary before building a restore script. */
|
/** Throwing variant — use at the boundary before building a restore script. */
|
||||||
@@ -133,6 +131,41 @@ export function assertTargetWithin(root: string, target: string, label = 'restor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** System paths a new-location restore must NEVER write into. The restore helper runs as
|
||||||
|
* root and bind-mounts targetPath:targetPath:rw, then restic writes `targetPath/volumes/...`,
|
||||||
|
* so an unguarded targetPath is arbitrary host-dir write. A plain new-location restore is a
|
||||||
|
* legitimate "extract wherever the operator picked" feature, so we don't confine it to one
|
||||||
|
* root; we reject absolute-path escapes and well-known sensitive roots instead. */
|
||||||
|
const FORBIDDEN_RESTORE_ROOTS = [
|
||||||
|
'/', '/etc', '/root', '/boot', '/dev', '/proc', '/sys', '/run',
|
||||||
|
'/var/run', '/bin', '/sbin', '/lib', '/lib64', '/var/lib/docker',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Returns a reason string if `targetPath` is unsafe for a new-location restore, else null.
|
||||||
|
* Uses resolve() so `/etc/../etc`, `/srv/../etc`, trailing slashes all normalize before the
|
||||||
|
* forbidden-root compare — a string-only prefix check would miss those. */
|
||||||
|
export function unsafeRestoreTargetReason(targetPath: string): string | null {
|
||||||
|
const raw = (targetPath ?? '').trim();
|
||||||
|
if (!raw) return 'a target path is required';
|
||||||
|
if (!raw.startsWith('/')) return 'target path must be absolute';
|
||||||
|
if (raw.includes('\0')) return 'target path must not contain NUL';
|
||||||
|
const norm = resolvePath(raw); // collapses .. and trailing slashes
|
||||||
|
for (const root of FORBIDDEN_RESTORE_ROOTS) {
|
||||||
|
if (norm === root || norm.startsWith(root === '/' ? '/' : root + pathSep)) {
|
||||||
|
// `/` matches everything by the prefix rule; only flag `/` itself for that root.
|
||||||
|
if (root === '/' && norm !== '/') continue;
|
||||||
|
return `target path "${norm}" is inside a protected system directory (${root})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throwing variant for the service layer (defense-in-depth behind API validation). */
|
||||||
|
export function assertSafeRestoreTarget(targetPath: string): void {
|
||||||
|
const reason = unsafeRestoreTargetReason(targetPath);
|
||||||
|
if (reason) throw new Error(`Refusing new-location restore: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a user-supplied name (stack/target) that could traverse the filesystem
|
* Reject a user-supplied name (stack/target) that could traverse the filesystem
|
||||||
* when joined into a path. Empty or containing a path separator / `..` → invalid.
|
* when joined into a path. Empty or containing a path separator / `..` → invalid.
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
}
|
||||||
@@ -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)' };
|
||||||
|
}
|
||||||
@@ -1,44 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* backups/swap.ts — the shell scripts that make an in-place volume restore safe.
|
* backups/swap.ts — the shell scripts that make an in-place volume restore safe.
|
||||||
|
* PURE string builders (no I/O), so the commit protocol is unit-testable against a
|
||||||
|
* real temp filesystem.
|
||||||
*
|
*
|
||||||
* These are PURE string builders (no I/O), so the exact commit protocol can be
|
* An in-place restore replaces a live volume's contents with a snapshot's. Deleting
|
||||||
* unit-tested by running the generated shell against a real temp filesystem.
|
* live data then copying the restore in is a data-loss trap: a mid-copy failure
|
||||||
*
|
* leaves an empty volume reported as success. Instead: restore ON THE SIDE first,
|
||||||
* THE PROBLEM. An in-place restore must replace the live contents of a volume
|
* then swap by moving directory entries, never deleting the original until the
|
||||||
* with a snapshot's contents. The naive "delete the live data, then copy the
|
* replacement is committed. Protocol per volume:
|
||||||
* restored data in" is a silent-data-loss trap: if the copy fails partway (disk
|
|
||||||
* full, permission, a mid-copy crash) the original is already gone and the
|
|
||||||
* restore reports success over an empty volume.
|
|
||||||
*
|
|
||||||
* THE DESIGN. Restore ON THE SIDE first, then swap by moving directory entries —
|
|
||||||
* never deleting the original until the replacement is committed:
|
|
||||||
*
|
*
|
||||||
* phase 0 recover any interrupted PRIOR swap (see recovery script) — never
|
* phase 0 recover any interrupted PRIOR swap (see recovery script) — never
|
||||||
* blindly delete `.dockhand-restore-old`, it may be the only copy of
|
* blindly delete `.dockhand-restore-old`, it may be the only copy.
|
||||||
* the original data.
|
* phase 1 restic restores into `<live>/.dockhand-restore-new` - a staging dir ON
|
||||||
* phase 1 restic restores into `<live>/.dockhand-restore-new` — a staging dir
|
* THE LIVE VOLUME'S OWN FILESYSTEM. Failure-prone; if it fails, live data
|
||||||
* ON THE LIVE VOLUME'S OWN FILESYSTEM. This is the failure-prone step;
|
* is untouched.
|
||||||
* if it fails the live data is still completely untouched.
|
|
||||||
* marker 2 move the live entries aside into `.dockhand-restore-old`
|
* marker 2 move the live entries aside into `.dockhand-restore-old`
|
||||||
* (same-filesystem renames — fast, and reversible).
|
* (same-filesystem renames - fast and reversible).
|
||||||
* marker 3 move the restored entries from `.dockhand-restore-new` up into the
|
* marker 3 move the restored entries from `.dockhand-restore-new` up into the
|
||||||
* live root (same-filesystem renames).
|
* live root (same-filesystem renames).
|
||||||
* done drop the marker and delete `.dockhand-restore-old` — the ONLY point
|
* done drop the marker and delete `.dockhand-restore-old` — the ONLY point
|
||||||
* at which the original data is deleted, and only after the new data
|
* at which the original is deleted, and only after the new data is in place.
|
||||||
* is fully in place.
|
|
||||||
*
|
*
|
||||||
* A same-filesystem `mv` of a directory entry is atomic. The move loops operate
|
* A same-filesystem `mv` of a directory entry is atomic; the loops move TOP-LEVEL
|
||||||
* on TOP-LEVEL entries (not a recursive per-file copy), so the window is a
|
* entries (not a recursive per-file copy), so the window is a handful of renames.
|
||||||
* handful of atomic renames rather than a long byte copy.
|
|
||||||
*
|
*
|
||||||
* THE CRASH CASE (the one thing fail-fast cannot cover). An external SIGKILL
|
* Crash case fail-fast cannot cover: an external SIGKILL (timeout, daemon restart,
|
||||||
* (restore timeout, daemon restart, reboot, OOM) can land BETWEEN the marker-2
|
* reboot, OOM) can land BETWEEN the marker-2 and marker-3 loops, leaving the live
|
||||||
* and marker-3 move loops, leaving the live root partly emptied with the
|
* root partly emptied with originals in `.dockhand-restore-old`. The phase marker
|
||||||
* originals in `.dockhand-restore-old`. The phase marker file makes that state
|
* makes that state detectable; the recovery script rolls it back. Recovery runs at
|
||||||
* detectable, and the recovery script rolls it back to the original content.
|
* the start of every restore AND on startup for a dead-process restore, so a
|
||||||
* This recovery runs at the start of every restore AND on startup for a restore
|
* half-swapped state is always reconciled before anything else touches the volume.
|
||||||
* whose process died — so the half-swapped state is always reconciled before
|
|
||||||
* anything else touches the volume.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Basenames of the swap artifacts created inside a live volume root. Exported
|
/** Basenames of the swap artifacts created inside a live volume root. Exported
|
||||||
@@ -101,8 +92,13 @@ function recoverOne(live: string): string {
|
|||||||
const newQ = sq(`${live}/${SWAP_NEW}`);
|
const newQ = sq(`${live}/${SWAP_NEW}`);
|
||||||
const oldQ = sq(`${live}/${SWAP_OLD}`);
|
const oldQ = sq(`${live}/${SWAP_OLD}`);
|
||||||
const phaseQ = sq(`${live}/${SWAP_PHASE}`);
|
const phaseQ = sq(`${live}/${SWAP_PHASE}`);
|
||||||
|
// Narrate ONLY when a marker is present (an interrupted prior swap is actually
|
||||||
|
// being rolled back), so a normal restore stays quiet but a re-run after a crash
|
||||||
|
// tells the user their volume is being recovered. Streamed to the restore log.
|
||||||
|
const say = (m: string) => `echo ${sq(`[dockhand] ${m}`)} >&2`;
|
||||||
return [
|
return [
|
||||||
`if [ -f ${phaseQ} ]; then`,
|
`if [ -f ${phaseQ} ]; then`,
|
||||||
|
` ${say(`Recovering ${live}: rolling back an interrupted previous restore`)};`,
|
||||||
` if [ "$(cat ${phaseQ})" = "3" ]; then ${removeEntries(liveQ)}; fi;`,
|
` if [ "$(cat ${phaseQ})" = "3" ]; then ${removeEntries(liveQ)}; fi;`,
|
||||||
` if [ -d ${oldQ} ]; then ${moveEntries(oldQ, liveQ)}; fi;`,
|
` if [ -d ${oldQ} ]; then ${moveEntries(oldQ, liveQ)}; fi;`,
|
||||||
` rm -rf ${newQ} ${oldQ}; rm -f ${phaseQ};`,
|
` rm -rf ${newQ} ${oldQ}; rm -f ${phaseQ};`,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* what's wrong with the request/config itself.
|
* what's wrong with the request/config itself.
|
||||||
*/
|
*/
|
||||||
import { isValidCron } from '../scheduler/cron-utils';
|
import { isValidCron } from '../scheduler/cron-utils';
|
||||||
import { isValidSnapshotId, isSafePathSegment } from './security';
|
import { isValidSnapshotId, isSafePathSegment, unsafeRestoreTargetReason } from './security';
|
||||||
import { parseRetention } from './retention';
|
import { parseRetention } from './retention';
|
||||||
|
|
||||||
/** A single validation problem: which field, and what's wrong. */
|
/** A single validation problem: which field, and what's wrong. */
|
||||||
@@ -138,6 +138,13 @@ export function validateRestoreRequest(input: RestoreRequestInput): ValidationRe
|
|||||||
if (input.mode === 'new-location' && !isClone && (!input.targetPath || !input.targetPath.trim())) {
|
if (input.mode === 'new-location' && !isClone && (!input.targetPath || !input.targetPath.trim())) {
|
||||||
issues.push({ field: 'targetPath', message: 'a target path is required for a new-location restore' });
|
issues.push({ field: 'targetPath', message: 'a target path is required for a new-location restore' });
|
||||||
}
|
}
|
||||||
|
// Containment: whenever a targetPath is supplied for a new-location restore it must not
|
||||||
|
// point at a protected system dir (the helper mounts it rw as root and restic writes into
|
||||||
|
// it). This is the guarantee validate.ts documents ("validated for containment by the service").
|
||||||
|
if (input.mode === 'new-location' && input.targetPath && input.targetPath.trim()) {
|
||||||
|
const reason = unsafeRestoreTargetReason(input.targetPath);
|
||||||
|
if (reason) issues.push({ field: 'targetPath', message: reason });
|
||||||
|
}
|
||||||
if (isClone) {
|
if (isClone) {
|
||||||
const mappedVols = new Set((input.volumeDestinations ?? []).map((d) => d.volume));
|
const mappedVols = new Set((input.volumeDestinations ?? []).map((d) => d.volume));
|
||||||
const looseCount = (input.volumes ?? []).filter((v) => !mappedVols.has(v)).length;
|
const looseCount = (input.volumes ?? []).filter((v) => !mappedVols.has(v)).length;
|
||||||
@@ -160,6 +167,11 @@ export function validateRestoreRequest(input: RestoreRequestInput): ValidationRe
|
|||||||
if (d.kind === 'path') {
|
if (d.kind === 'path') {
|
||||||
if (typeof d.target !== 'string' || !d.target.startsWith('/') || d.target.includes('..')) {
|
if (typeof d.target !== 'string' || !d.target.startsWith('/') || d.target.includes('..')) {
|
||||||
issues.push({ field: 'volumeDestinations', message: `path destination for "${d.volume}" must be an absolute path (no "..")` });
|
issues.push({ field: 'volumeDestinations', message: `path destination for "${d.volume}" must be an absolute path (no "..")` });
|
||||||
|
} else {
|
||||||
|
// Same protected-root guard as targetPath: the helper mounts this path rw as root
|
||||||
|
// and restic writes into it, so a clone must never target /etc, /var/run, etc.
|
||||||
|
const reason = unsafeRestoreTargetReason(d.target);
|
||||||
|
if (reason) issues.push({ field: 'volumeDestinations', message: `path destination for "${d.volume}": ${reason}` });
|
||||||
}
|
}
|
||||||
} else if (d.kind === 'volume') {
|
} else if (d.kind === 'volume') {
|
||||||
if (typeof d.target !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(d.target)) {
|
if (typeof d.target !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(d.target)) {
|
||||||
|
|||||||
@@ -164,18 +164,6 @@ export async function startScheduler(): Promise<void> {
|
|||||||
console.warn(`[Scheduler] Orphan helper reap failed: ${err instanceof Error ? err.message : String(err)}`);
|
console.warn(`[Scheduler] Orphan helper reap failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// New backup/restore engine: roll back any restore swap it left interrupted,
|
|
||||||
// then scrub operations left `running` by a dead process. Runs after the orphan
|
|
||||||
// helper reap above; it recovers swaps recorded by the engine's journal.
|
|
||||||
try {
|
|
||||||
const { reconcileOnStartup } = await import('../backups');
|
|
||||||
const { swapsRolledBack, containersRestarted, opsScrubbed } = await reconcileOnStartup();
|
|
||||||
if (swapsRolledBack > 0) console.log(`[Scheduler] Rolled back ${swapsRolledBack} interrupted restore swap(s)`);
|
|
||||||
if (containersRestarted > 0) console.log(`[Scheduler] Restarted ${containersRestarted} target(s) left stopped for backup/restore`);
|
|
||||||
if (opsScrubbed > 0) console.log(`[Scheduler] Scrubbed ${opsScrubbed} interrupted operation(s)`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[Scheduler] Backup engine startup reconciliation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get cron expressions and default timezone from database
|
// Get cron expressions and default timezone from database
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { auditBackup } from '$lib/server/audit';
|
import { auditBackup } from '$lib/server/audit';
|
||||||
import {
|
import {
|
||||||
getBackupConfigs,
|
getBackupConfigs,
|
||||||
@@ -12,9 +13,8 @@ import { validateRetention, retentionToStore } from '$lib/server/backups/helpers
|
|||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
const denied = await requireBackups(auth, 'view');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const type = url.searchParams.get('type') ?? undefined;
|
const type = url.searchParams.get('type') ?? undefined;
|
||||||
const targetName = url.searchParams.get('target') ?? undefined;
|
const targetName = url.searchParams.get('target') ?? undefined;
|
||||||
@@ -45,9 +45,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { request, cookies } = event;
|
const { request, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
const denied = await requireBackups(auth, 'manage');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { auditBackupDestination } from '$lib/server/audit';
|
import { auditBackupDestination } from '$lib/server/audit';
|
||||||
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
|
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
|
||||||
import { initRepository } from '$lib/server/backups';
|
import { initRepository } from '$lib/server/backups';
|
||||||
@@ -8,9 +9,8 @@ import { initRepository } from '$lib/server/backups';
|
|||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, cookies } = event;
|
const { params, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
const denied = await requireBackups(auth, 'manage');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { auditBackupDestination } from '$lib/server/audit';
|
import { auditBackupDestination } from '$lib/server/audit';
|
||||||
import { getBackupDestination } from '$lib/server/db';
|
import { getBackupDestination } from '$lib/server/db';
|
||||||
import { rotateDestinationPassword } from '$lib/server/backups';
|
import { rotateDestinationPassword } from '$lib/server/backups';
|
||||||
@@ -20,9 +21,8 @@ import { rotateDestinationPassword } from '$lib/server/backups';
|
|||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, request, cookies } = event;
|
const { params, request, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
const denied = await requireBackups(auth, 'manage');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import {
|
import {
|
||||||
getBackupDestination,
|
getBackupDestination,
|
||||||
updateBackupDestinationTestStatus
|
updateBackupDestinationTestStatus
|
||||||
@@ -9,9 +10,8 @@ import { testRepository } from '$lib/server/backups';
|
|||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
const denied = await requireBackups(auth, 'manage');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { auditBackupDestination } from '$lib/server/audit';
|
import { auditBackupDestination } from '$lib/server/audit';
|
||||||
import { getBackupDestination } from '$lib/server/db';
|
import { getBackupDestination } from '$lib/server/db';
|
||||||
import { verifyBackup } from '$lib/server/backups';
|
import { verifyBackup } from '$lib/server/backups';
|
||||||
@@ -9,9 +10,8 @@ import { createJobResponse } from '$lib/server/sse';
|
|||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, request, cookies } = event;
|
const { params, request, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
const denied = await requireBackups(auth, 'manage');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const destinationId = parseInt(params.id);
|
const destinationId = parseInt(params.id);
|
||||||
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
|
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { auditRestore } from '$lib/server/audit';
|
import { auditRestore } from '$lib/server/audit';
|
||||||
import { cancelRestore } from '$lib/server/backups';
|
import { cancelRestore } from '$lib/server/backups';
|
||||||
import { isValidSnapshotId } from '$lib/server/backups/helpers';
|
import { isValidSnapshotId } from '$lib/server/backups/helpers';
|
||||||
@@ -14,9 +15,8 @@ import { isValidSnapshotId } from '$lib/server/backups/helpers';
|
|||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { request, cookies } = event;
|
const { request, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
const denied = await requireBackups(auth, 'manage');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const snapshotId: string | undefined = body.snapshotId;
|
const snapshotId: string | undefined = body.snapshotId;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { auditBackup } from '$lib/server/audit';
|
import { auditBackup } from '$lib/server/audit';
|
||||||
import { getBackupDestination } from '$lib/server/db';
|
import { getBackupDestination } from '$lib/server/db';
|
||||||
import { forgetSnapshot } from '$lib/server/backups';
|
import { forgetSnapshot } from '$lib/server/backups';
|
||||||
@@ -10,9 +11,8 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
|||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
const rbacDenied = await requireBackups(auth, 'manage');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (rbacDenied) return rbacDenied;
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotId = params.id;
|
const snapshotId = params.id;
|
||||||
const invalidSnap = validateSnapshotId(snapshotId);
|
const invalidSnap = validateSnapshotId(snapshotId);
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { browseSnapshot } from '$lib/server/backups';
|
import { browseSnapshot } from '$lib/server/backups';
|
||||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
const denied = await requireBackups(auth, 'view');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotId = params.id;
|
const snapshotId = params.id;
|
||||||
const invalidSnap = validateSnapshotId(snapshotId);
|
const invalidSnap = validateSnapshotId(snapshotId);
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups';
|
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups';
|
||||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||||
|
import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
const denied = await requireBackups(auth, 'view');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotId = params.id;
|
const snapshotId = params.id;
|
||||||
const invalidSnap = validateSnapshotId(snapshotId);
|
const invalidSnap = validateSnapshotId(snapshotId);
|
||||||
@@ -48,6 +49,26 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
return json({ error: 'Invalid path' }, { status: 400 });
|
return json({ error: 'Invalid path' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metadata.json carries secrets (stack.secrets ciphertext + container Config.Env
|
||||||
|
// plaintext). It must ONLY leave the process through the redacting path — never as a
|
||||||
|
// raw file/byte/archive dump, which would bypass redaction. Serve a redacted inline
|
||||||
|
// preview; refuse raw downloads and reject a /metadata archive dump that would embed it.
|
||||||
|
const isMetadataFile = path === '/metadata/metadata.json';
|
||||||
|
if (isMetadataFile || (isDir && download && (path === '/metadata' || path === '/metadata/'))) {
|
||||||
|
if (download) {
|
||||||
|
return json({ error: 'metadata.json cannot be downloaded raw; use the snapshot metadata endpoint (secrets are redacted there)' }, { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await dumpSnapshotFile(destinationId, snapshotId, '/metadata/metadata.json');
|
||||||
|
const layout = parseSnapshotLayout(raw);
|
||||||
|
if (!layout) return json({ error: 'metadata unreadable' }, { status: 404 });
|
||||||
|
return json({ content: JSON.stringify(redactSnapshotLayout(layout), null, 2) });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return json({ error: errorMsg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
|
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
|
||||||
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
|
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { getSnapshotMetadata } from '$lib/server/backups';
|
import { getSnapshotMetadata } from '$lib/server/backups';
|
||||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||||
|
import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
const denied = await requireBackups(auth, 'view');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotId = params.id;
|
const snapshotId = params.id;
|
||||||
const invalidSnap = validateSnapshotId(snapshotId);
|
const invalidSnap = validateSnapshotId(snapshotId);
|
||||||
@@ -28,15 +29,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
try {
|
try {
|
||||||
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
|
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
|
||||||
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
|
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
|
||||||
// Never expose the stored secret VALUES (ciphertext) to the client — replace the
|
// Single redaction point: strips stack.secrets values AND container inspect
|
||||||
// `secrets` array with `secretKeys` (names only) so the UI can list them.
|
// Config.Env/Labels (plaintext secrets) before anything reaches the client.
|
||||||
const { secrets, ...safe } = metadata as Record<string, unknown>;
|
return json(redactSnapshotLayout(metadata));
|
||||||
if (Array.isArray(secrets)) {
|
|
||||||
(safe as Record<string, unknown>).secretKeys = secrets
|
|
||||||
.map((s: any) => s?.key)
|
|
||||||
.filter((k: unknown): k is string => typeof k === 'string');
|
|
||||||
}
|
|
||||||
return json(safe);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
return json({ error: msg }, { status: 500 });
|
return json({ error: msg }, { status: 500 });
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||||
import { diffSnapshots } from '$lib/server/backups';
|
import { diffSnapshots } from '$lib/server/backups';
|
||||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
const denied = await requireBackups(auth, 'view');
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
if (denied) return denied;
|
||||||
}
|
|
||||||
|
|
||||||
const destId = url.searchParams.get('destinationId');
|
const destId = url.searchParams.get('destinationId');
|
||||||
const snapA = url.searchParams.get('snapshotA');
|
const snapA = url.searchParams.get('snapshotA');
|
||||||
|
|||||||
@@ -106,6 +106,21 @@
|
|||||||
const isVolumeMode = $derived(mode === 'volume');
|
const isVolumeMode = $derived(mode === 'volume');
|
||||||
const isSnapshotMode = $derived(mode === 'snapshot');
|
const isSnapshotMode = $derived(mode === 'snapshot');
|
||||||
|
|
||||||
|
// In a snapshot, the stack dir rides a reserved volume key; show it by what it holds,
|
||||||
|
// not the raw internal key. Navigation still uses the real name.
|
||||||
|
function displayEntryName(name: string): string {
|
||||||
|
if (isSnapshotMode && name === '__dockhand_stackdir__') return 'Stack files (compose, config)';
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw download of the snapshot's metadata.json is refused server-side (403) — it carries
|
||||||
|
// secrets and must only leave through the redacting metadata endpoint. So don't offer a
|
||||||
|
// download button that would always fail; the file is still previewable (redacted).
|
||||||
|
function canDownloadEntry(entry: FileEntry): boolean {
|
||||||
|
if (!isSnapshotMode) return true;
|
||||||
|
return !(entry.name === 'metadata.json' && (currentPath === '/metadata' || currentPath === '/metadata/'));
|
||||||
|
}
|
||||||
|
|
||||||
// Effective canEdit: snapshots are always read-only; volumes only if not in use
|
// Effective canEdit: snapshots are always read-only; volumes only if not in use
|
||||||
const effectiveCanEdit = $derived(
|
const effectiveCanEdit = $derived(
|
||||||
isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit
|
isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit
|
||||||
@@ -934,7 +949,7 @@
|
|||||||
title={segment}
|
title={segment}
|
||||||
onclick={() => navigateTo('/' + pathSegments().slice(0, i + 1).join('/'))}
|
onclick={() => navigateTo('/' + pathSegments().slice(0, i + 1).join('/'))}
|
||||||
>
|
>
|
||||||
{segment}
|
{displayEntryName(segment)}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -1076,14 +1091,16 @@
|
|||||||
onclick={() => handleEntryClick(entry)}
|
onclick={() => handleEntryClick(entry)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
class="w-3.5 h-3.5 shrink-0 {entry.type === 'directory'
|
class="w-3.5 h-3.5 shrink-0 {isSnapshotMode && entry.name === '__dockhand_stackdir__'
|
||||||
? 'text-blue-500'
|
? 'text-amber-500'
|
||||||
: entry.type === 'symlink'
|
: entry.type === 'directory'
|
||||||
? 'text-purple-500'
|
? 'text-blue-500'
|
||||||
: 'text-muted-foreground'}"
|
: entry.type === 'symlink'
|
||||||
|
? 'text-purple-500'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
/>
|
/>
|
||||||
<span class="truncate" title={entry.name}>
|
<span class="truncate" title={entry.name}>
|
||||||
{entry.name}
|
{displayEntryName(entry.name)}
|
||||||
{#if entry.type === 'symlink' && entry.linkTarget}
|
{#if entry.type === 'symlink' && entry.linkTarget}
|
||||||
<span class="text-muted-foreground ml-1">
|
<span class="text-muted-foreground ml-1">
|
||||||
→ {entry.linkTarget}
|
→ {entry.linkTarget}
|
||||||
@@ -1188,15 +1205,17 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ConfirmPopover>
|
</ConfirmPopover>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
{#if canDownloadEntry(entry)}
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
size="icon"
|
||||||
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
|
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
title="Download"
|
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
|
||||||
>
|
title="Download"
|
||||||
<Download class="w-3 h-3" />
|
>
|
||||||
</Button>
|
<Download class="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||||
import { environments } from '$lib/stores/environment';
|
import { environments } from '$lib/stores/environment';
|
||||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
|
import SnapshotHeader from '$lib/components/backup/SnapshotHeader.svelte';
|
||||||
import FileBrowserPanel from './FileBrowserPanel.svelte';
|
import FileBrowserPanel from './FileBrowserPanel.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -105,33 +106,21 @@
|
|||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
<Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden">
|
<Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2 flex-wrap">
|
<Dialog.Title>
|
||||||
<FolderOpen class="w-5 h-5" />
|
<SnapshotHeader
|
||||||
Browse snapshot
|
icon={FolderOpen}
|
||||||
{#if snapshotEnvName}
|
verb="Browse snapshot"
|
||||||
<span class="text-muted-foreground font-normal">from</span>
|
name={displayName}
|
||||||
<EnvironmentIcon icon={snapshotEnvIcon} envId={snapshotEnvId} class="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
nameType={(targetType || metadata?.type) === 'stack' ? 'stack' : 'container'}
|
||||||
<span class="font-normal text-amber-600 dark:text-amber-400">{snapshotEnvName}</span>
|
{destinationName}
|
||||||
{/if}
|
destinationRepository={destinationRepo}
|
||||||
{#if displayName}
|
sourceEnv={{ id: snapshotEnvId, icon: snapshotEnvIcon }}
|
||||||
<span class="text-muted-foreground font-normal">—</span>
|
sourceEnvName={snapshotEnvName}
|
||||||
{#if (targetType || metadata?.type) === 'stack'}
|
snapshotId={snapshotId ? String(snapshotId) : undefined}
|
||||||
<Layers class="w-4 h-4 text-purple-500" />
|
{snapshotTime}
|
||||||
{:else}
|
/>
|
||||||
<Box class="w-4 h-4 text-blue-500" />
|
|
||||||
{/if}
|
|
||||||
<span class="font-normal">{displayName}</span>
|
|
||||||
{/if}
|
|
||||||
{#if destinationName}
|
|
||||||
<span class="text-muted-foreground font-normal">on</span>
|
|
||||||
<svelte:component this={getRepoTypeIcon(destinationRepo)} class="w-4 h-4" />
|
|
||||||
<span class="font-normal">{destinationName}</span>
|
|
||||||
{/if}
|
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground">
|
<Dialog.Description class="sr-only">Browse files in snapshot {snapshotId ? String(snapshotId).slice(0, 8) : ''}.</Dialog.Description>
|
||||||
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
|
|
||||||
{#if snapshotTime}{formatDateTime(snapshotTime)} <span class="opacity-60">({formatRelativeTime(snapshotTime)})</span>{/if}
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
@@ -192,12 +181,12 @@
|
|||||||
<dt class="text-muted-foreground">Volumes</dt>
|
<dt class="text-muted-foreground">Volumes</dt>
|
||||||
<dd>{metadata.volumes?.length ?? 0}</dd>
|
<dd>{metadata.volumes?.length ?? 0}</dd>
|
||||||
{#if metadata.type === 'stack'}
|
{#if metadata.type === 'stack'}
|
||||||
{#if metadata.composeFileName}
|
{#if metadata.stack?.composeFileName}
|
||||||
<dt class="text-muted-foreground">Compose file</dt>
|
<dt class="text-muted-foreground">Compose file</dt>
|
||||||
<dd class="font-mono">{metadata.composeFileName}</dd>
|
<dd class="font-mono">{metadata.stack.composeFileName}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
<dt class="text-muted-foreground">Stack files</dt>
|
<dt class="text-muted-foreground">Stack files</dt>
|
||||||
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd>
|
<dd>{metadata.hasStackFiles ? 'captured' : 'not captured'}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
@@ -229,34 +218,31 @@
|
|||||||
<!-- Stack files (captured under stackfiles/). The list is recorded in
|
<!-- Stack files (captured under stackfiles/). The list is recorded in
|
||||||
metadata.json at backup time so we can enumerate the exact files
|
metadata.json at backup time so we can enumerate the exact files
|
||||||
without a browse roundtrip. -->
|
without a browse roundtrip. -->
|
||||||
{#if metadata.type === 'stack' && metadata.stackFilesList?.length}
|
{#if metadata.type === 'stack' && metadata.stack?.fileList?.length}
|
||||||
<section class="space-y-2 border-t pt-4">
|
<section class="space-y-2 border-t pt-4">
|
||||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})</h4>
|
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stack.fileList.length})</h4>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each metadata.stackFilesList as file}
|
{#each metadata.stack.fileList as file}
|
||||||
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
|
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
|
||||||
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.composeFileName}>{file.path}</span>
|
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.stack.composeFileName}>{file.path}</span>
|
||||||
{#if file.path === metadata.composeFileName}
|
{#if file.path === metadata.stack.composeFileName}
|
||||||
<Badge variant="outline" class="text-[10px] shrink-0">compose</Badge>
|
<Badge variant="outline" class="text-[10px] shrink-0">compose</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span>
|
<span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if metadata.stackFilesListTruncated}
|
|
||||||
<p class="text-xs text-muted-foreground">Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Secrets carried in the snapshot (KEY NAMES only — the values are
|
<!-- Secrets carried in the snapshot (KEY NAMES only — the values are
|
||||||
stored encrypted and never exposed here). Restored to the target
|
stored encrypted and never exposed here). Restored to the target
|
||||||
DB on restore unless the user opts out. -->
|
DB on restore unless the user opts out. -->
|
||||||
{#if metadata.type === 'stack' && metadata.secretKeys?.length}
|
{#if metadata.type === 'stack' && metadata.stack?.secretKeys?.length}
|
||||||
<section class="space-y-2 border-t pt-4">
|
<section class="space-y-2 border-t pt-4">
|
||||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.secretKeys.length})</h4>
|
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.stack.secretKeys.length})</h4>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each metadata.secretKeys as key}
|
{#each metadata.stack.secretKeys as key}
|
||||||
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
|
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user