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

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

No files matched your search

+5 -6
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackup } from '$lib/server/audit';
import {
getBackupConfigs,
@@ -12,9 +13,8 @@ import { validateRetention, retentionToStore } from '$lib/server/backups/helpers
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
const type = url.searchParams.get('type') ?? undefined;
const targetName = url.searchParams.get('target') ?? undefined;
@@ -45,9 +45,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const body = await request.json();
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
import { initRepository } from '$lib/server/backups';
@@ -8,9 +9,8 @@ import { initRepository } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { rotateDestinationPassword } from '$lib/server/backups';
@@ -20,9 +21,8 @@ import { rotateDestinationPassword } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import {
getBackupDestination,
updateBackupDestinationTestStatus
@@ -9,9 +10,8 @@ import { testRepository } from '$lib/server/backups';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { verifyBackup } from '$lib/server/backups';
@@ -9,9 +10,8 @@ import { createJobResponse } from '$lib/server/sse';
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const destinationId = parseInt(params.id);
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditRestore } from '$lib/server/audit';
import { cancelRestore } from '$lib/server/backups';
import { isValidSnapshotId } from '$lib/server/backups/helpers';
@@ -14,9 +15,8 @@ import { isValidSnapshotId } from '$lib/server/backups/helpers';
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const body = await request.json().catch(() => ({}));
const snapshotId: string | undefined = body.snapshotId;
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackup } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { forgetSnapshot } from '$lib/server/backups';
@@ -10,9 +11,8 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const rbacDenied = await requireBackups(auth, 'manage');
if (rbacDenied) return rbacDenied;
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
@@ -2,14 +2,14 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { browseSnapshot } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
@@ -48,6 +49,26 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({ error: 'Invalid path' }, { status: 400 });
}
// metadata.json carries secrets (stack.secrets ciphertext + container Config.Env
// plaintext). It must ONLY leave the process through the redacting path — never as a
// raw file/byte/archive dump, which would bypass redaction. Serve a redacted inline
// preview; refuse raw downloads and reject a /metadata archive dump that would embed it.
const isMetadataFile = path === '/metadata/metadata.json';
if (isMetadataFile || (isDir && download && (path === '/metadata' || path === '/metadata/'))) {
if (download) {
return json({ error: 'metadata.json cannot be downloaded raw; use the snapshot metadata endpoint (secrets are redacted there)' }, { status: 403 });
}
try {
const raw = await dumpSnapshotFile(destinationId, snapshotId, '/metadata/metadata.json');
const layout = parseSnapshotLayout(raw);
if (!layout) return json({ error: 'metadata unreadable' }, { status: 404 });
return json({ content: JSON.stringify(redactSnapshotLayout(layout), null, 2) });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
}
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { getSnapshotMetadata } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
@@ -28,15 +29,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
// Never expose the stored secret VALUES (ciphertext) to the client — replace the
// `secrets` array with `secretKeys` (names only) so the UI can list them.
const { secrets, ...safe } = metadata as Record<string, unknown>;
if (Array.isArray(secrets)) {
(safe as Record<string, unknown>).secretKeys = secrets
.map((s: any) => s?.key)
.filter((k: unknown): k is string => typeof k === 'string');
}
return json(safe);
// Single redaction point: strips stack.secrets values AND container inspect
// Config.Env/Labels (plaintext secrets) before anything reaches the client.
return json(redactSnapshotLayout(metadata));
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return json({ error: msg }, { status: 500 });
@@ -1,15 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { diffSnapshots } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { validateSnapshotId } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
const destId = url.searchParams.get('destinationId');
const snapA = url.searchParams.get('snapshotA');
+35 -16
View File
@@ -106,6 +106,21 @@
const isVolumeMode = $derived(mode === 'volume');
const isSnapshotMode = $derived(mode === 'snapshot');
// In a snapshot, the stack dir rides a reserved volume key; show it by what it holds,
// not the raw internal key. Navigation still uses the real name.
function displayEntryName(name: string): string {
if (isSnapshotMode && name === '__dockhand_stackdir__') return 'Stack files (compose, config)';
return name;
}
// A raw download of the snapshot's metadata.json is refused server-side (403) — it carries
// secrets and must only leave through the redacting metadata endpoint. So don't offer a
// download button that would always fail; the file is still previewable (redacted).
function canDownloadEntry(entry: FileEntry): boolean {
if (!isSnapshotMode) return true;
return !(entry.name === 'metadata.json' && (currentPath === '/metadata' || currentPath === '/metadata/'));
}
// Effective canEdit: snapshots are always read-only; volumes only if not in use
const effectiveCanEdit = $derived(
isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit
@@ -934,7 +949,7 @@
title={segment}
onclick={() => navigateTo('/' + pathSegments().slice(0, i + 1).join('/'))}
>
{segment}
{displayEntryName(segment)}
</button>
{/each}
</div>
@@ -1076,14 +1091,16 @@
onclick={() => handleEntryClick(entry)}
>
<Icon
class="w-3.5 h-3.5 shrink-0 {entry.type === 'directory'
? 'text-blue-500'
: entry.type === 'symlink'
? 'text-purple-500'
: 'text-muted-foreground'}"
class="w-3.5 h-3.5 shrink-0 {isSnapshotMode && entry.name === '__dockhand_stackdir__'
? 'text-amber-500'
: entry.type === 'directory'
? 'text-blue-500'
: entry.type === 'symlink'
? 'text-purple-500'
: 'text-muted-foreground'}"
/>
<span class="truncate" title={entry.name}>
{entry.name}
{displayEntryName(entry.name)}
{#if entry.type === 'symlink' && entry.linkTarget}
<span class="text-muted-foreground ml-1">
→ {entry.linkTarget}
@@ -1188,15 +1205,17 @@
{/snippet}
</ConfirmPopover>
{/if}
<Button
variant="ghost"
size="icon"
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
title="Download"
>
<Download class="w-3 h-3" />
</Button>
{#if canDownloadEntry(entry)}
<Button
variant="ghost"
size="icon"
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
title="Download"
>
<Download class="w-3 h-3" />
</Button>
{/if}
</div>
</Table.Cell>
</Table.Row>
+26 -40
View File
@@ -12,6 +12,7 @@
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
import { environments } from '$lib/stores/environment';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import SnapshotHeader from '$lib/components/backup/SnapshotHeader.svelte';
import FileBrowserPanel from './FileBrowserPanel.svelte';
interface Props {
@@ -105,33 +106,21 @@
<Dialog.Root bind:open>
<Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 flex-wrap">
<FolderOpen class="w-5 h-5" />
Browse snapshot
{#if snapshotEnvName}
<span class="text-muted-foreground font-normal">from</span>
<EnvironmentIcon icon={snapshotEnvIcon} envId={snapshotEnvId} class="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span class="font-normal text-amber-600 dark:text-amber-400">{snapshotEnvName}</span>
{/if}
{#if displayName}
<span class="text-muted-foreground font-normal">—</span>
{#if (targetType || metadata?.type) === 'stack'}
<Layers class="w-4 h-4 text-purple-500" />
{:else}
<Box class="w-4 h-4 text-blue-500" />
{/if}
<span class="font-normal">{displayName}</span>
{/if}
{#if destinationName}
<span class="text-muted-foreground font-normal">on</span>
<svelte:component this={getRepoTypeIcon(destinationRepo)} class="w-4 h-4" />
<span class="font-normal">{destinationName}</span>
{/if}
<Dialog.Title>
<SnapshotHeader
icon={FolderOpen}
verb="Browse snapshot"
name={displayName}
nameType={(targetType || metadata?.type) === 'stack' ? 'stack' : 'container'}
{destinationName}
destinationRepository={destinationRepo}
sourceEnv={{ id: snapshotEnvId, icon: snapshotEnvIcon }}
sourceEnvName={snapshotEnvName}
snapshotId={snapshotId ? String(snapshotId) : undefined}
{snapshotTime}
/>
</Dialog.Title>
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground">
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
{#if snapshotTime}{formatDateTime(snapshotTime)} <span class="opacity-60">({formatRelativeTime(snapshotTime)})</span>{/if}
</Dialog.Description>
<Dialog.Description class="sr-only">Browse files in snapshot {snapshotId ? String(snapshotId).slice(0, 8) : ''}.</Dialog.Description>
</Dialog.Header>
<!-- Tabs -->
@@ -192,12 +181,12 @@
<dt class="text-muted-foreground">Volumes</dt>
<dd>{metadata.volumes?.length ?? 0}</dd>
{#if metadata.type === 'stack'}
{#if metadata.composeFileName}
{#if metadata.stack?.composeFileName}
<dt class="text-muted-foreground">Compose file</dt>
<dd class="font-mono">{metadata.composeFileName}</dd>
<dd class="font-mono">{metadata.stack.composeFileName}</dd>
{/if}
<dt class="text-muted-foreground">Stack files</dt>
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd>
<dd>{metadata.hasStackFiles ? 'captured' : 'not captured'}</dd>
{/if}
</dl>
</section>
@@ -229,34 +218,31 @@
<!-- Stack files (captured under stackfiles/). The list is recorded in
metadata.json at backup time so we can enumerate the exact files
without a browse roundtrip. -->
{#if metadata.type === 'stack' && metadata.stackFilesList?.length}
{#if metadata.type === 'stack' && metadata.stack?.fileList?.length}
<section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stack.fileList.length})</h4>
<div class="space-y-1">
{#each metadata.stackFilesList as file}
{#each metadata.stack.fileList as file}
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.composeFileName}>{file.path}</span>
{#if file.path === metadata.composeFileName}
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.stack.composeFileName}>{file.path}</span>
{#if file.path === metadata.stack.composeFileName}
<Badge variant="outline" class="text-[10px] shrink-0">compose</Badge>
{/if}
<span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span>
</div>
{/each}
</div>
{#if metadata.stackFilesListTruncated}
<p class="text-xs text-muted-foreground">Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.</p>
{/if}
</section>
{/if}
<!-- Secrets carried in the snapshot (KEY NAMES only — the values are
stored encrypted and never exposed here). Restored to the target
DB on restore unless the user opts out. -->
{#if metadata.type === 'stack' && metadata.secretKeys?.length}
{#if metadata.type === 'stack' && metadata.stack?.secretKeys?.length}
<section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.secretKeys.length})</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.stack.secretKeys.length})</h4>
<div class="flex flex-wrap gap-1">
{#each metadata.secretKeys as key}
{#each metadata.stack.secretKeys as key}
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
{/each}
</div>