read-side backup snapshot endpoints use job polling

This commit is contained in:
jarek committed 2026-08-13 10:20:30 +02:00
1 parent 315c795058
commit 610e46dc19
15 files changed
+224 -165

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
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 { getInstanceId } from '$lib/server/backups/identity';
// This install's stable instance id. The snapshots list used to ride it on an
// X-Dockhand-Instance response header, but that list is now job-polled (a proxy would
// abort the sync `restic snapshots` at ~15s) and headers don't survive polling. The
// client fetches the id here once and matches it against each snapshot's
// dockhand:instance= tag to tell own snapshots from foreign orphans (#1351).
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
return json({ instanceId: await getInstanceId() });
};
@@ -5,6 +5,7 @@ import { requireBackups } from '$lib/server/backups/route-guards';
import { previewSnapshot, previewRestoreTargets } from '$lib/server/backups';
import { validateSnapshotId } from '$lib/server/docker-validation';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { jobResult } from '$lib/server/sse';
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
@@ -26,7 +27,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
const denied = await guardSnapshotEnvAccess(auth, body.destinationId, body.snapshotId);
if (denied) return denied;
try {
// Job-polling: the preview runs restic (verify-ownership + ls/dump) and, with a mode,
// per-volume probe helpers - all slow enough that a reverse proxy would abort the sync
// request at ~15s and SIGTERM the restic mid-op (the "wrong password / signal terminated"
// red herrings). Returning {jobId} immediately keeps the connection alive; the client polls.
return jobResult(request, async () => {
const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) };
const preview = await previewSnapshot(body.destinationId, body.snapshotId, access);
// When the caller supplies a restore mode, ALSO resolve the exact on-disk targets and probe
@@ -44,11 +49,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
mergeStackFiles: body.mergeStackFiles,
volumes: body.volumes,
}, access);
return json({ ...preview, targets });
return { ...preview, targets };
}
return json(preview);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
return preview;
});
};
+24 -37
View File
@@ -5,8 +5,9 @@ import { requireBackups } from '$lib/server/backups/route-guards';
import { getBackupConfig, getBackupDestinations } from '$lib/server/db';
import { listSnapshots } from '$lib/server/backups';
import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards';
import { jobResult } from '$lib/server/sse';
export const GET: RequestHandler = async ({ url, cookies }) => {
export const GET: RequestHandler = async ({ url, cookies, request }) => {
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
@@ -30,13 +31,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
// Narrow to THIS config's identity by its stable config-id tag so a sibling
// config sharing the same destination + targetName isn't mixed in.
if (allDests) {
// Search ALL destinations for this config's snapshots
try {
// Search ALL destinations for this config's snapshots.
// Job-polling: `restic snapshots` across every destination is the slowest read here
// and a reverse proxy would abort the sync request at ~15s. The result stays a bare
// snapshot array (unchanged contract - many callers/tests treat it as an array); the
// old X-Incomplete-Destinations header is now log-only.
return jobResult(request, async () => {
const destinations = await getBackupDestinations();
// Bound each destination's listing so one slow or unreachable repo
// can't stall the whole response for minutes (restic's own timeout is
// several minutes). A timed-out destination is treated as a failed
// one — surfaced as a partial result via X-Incomplete-Destinations.
// several minutes). A timed-out destination is treated as a failed one.
const PER_DEST_MS = Number(process.env.SNAPSHOT_LIST_TIMEOUT_MS ?? 30_000);
const withTimeout = <T>(p: Promise<T>, name: string): Promise<T> =>
Promise.race([
@@ -50,10 +54,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
);
const allSnapshots: any[] = [];
const seen = new Set<string>();
// (audit #29) Don't silently drop destinations that errored. Collect
// the failures and surface them via a response header (keeping the body
// a plain array for compatibility), plus a server log — so a partial
// result doesn't look complete.
// (audit #29) Don't silently drop destinations that errored. Collect the
// failures and surface a count so a partial result doesn't look complete.
const failed: string[] = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
@@ -72,48 +74,33 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
allSnapshots.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
if (failed.length > 0) {
console.warn(`[Backup] Snapshot search incomplete — ${failed.length} destination(s) failed: ${failed.join('; ')}`);
return json(allSnapshots, { headers: { 'X-Incomplete-Destinations': String(failed.length) } });
}
return json(allSnapshots);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
return allSnapshots;
});
}
// Default: only search the config's current destination
try {
const snapshots = await listSnapshots(config.destinationId, config.id);
return json(snapshots);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
// Default: only search the config's current destination.
return jobResult(request, () => listSnapshots(config.destinationId, config.id));
} else if (destIdParam) {
const destinationId = parseInt(destIdParam);
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
try {
// Job-polling: `restic snapshots` would otherwise be aborted at the proxy's ~15s cap.
// Returns a bare snapshot array (unchanged contract). The per-snapshot dockhand:instance=
// tag identifies the writing install; the client learns its OWN id from
// GET /api/backup/instance (the old X-Dockhand-Instance header can't ride job polling).
return jobResult(request, async () => {
// No target filter — return all snapshots in this destination (incl. snapshots
// from OTHER Dockhand instances sharing/copied into the repo). The response carries
// this install's instance id so the client can tell own snapshots from foreign
// orphans without relying on configid (which collides across instances - #1351).
const { getInstanceId } = await import('$lib/server/backups/identity');
const thisInstanceId = await getInstanceId();
// from OTHER Dockhand instances sharing/copied into the repo).
const snapshots = await listSnapshots(destinationId);
// (HIGH #8) On enterprise, a non-admin must not see snapshots belonging to
// environments they can't access — the ids/paths alone are disclosure. Drop
// any snapshot whose owning env (from its dockhand:env tag) isn't accessible.
const headers = { 'X-Dockhand-Instance': thisInstanceId };
if (auth.isEnterprise) {
const filtered = await filterSnapshotsByEnvAccess(auth, snapshots);
return json(filtered, { headers });
return await filterSnapshotsByEnvAccess(auth, snapshots);
}
return json(snapshots, { headers });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
return snapshots;
});
} else {
return json({ error: 'configId or destinationId parameter is required' }, { status: 400 });
}
@@ -5,8 +5,9 @@ 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';
import { jobResult } from '$lib/server/sse';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
@@ -38,11 +39,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({ error: 'Environment access denied' }, { status: 403 });
}
try {
// Job-polling: `restic ls` behind a reverse proxy would abort at ~15s and SIGTERM restic
// (surfacing a misleading "wrong password / signal terminated"). Return {jobId} at once.
return jobResult(request, async () => {
const entries = await browseSnapshot(destinationId, snapshotId, path);
return json({ entries, path });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
return { entries, path };
});
};
@@ -6,8 +6,9 @@ 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';
import { jobResult } from '$lib/server/sse';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
@@ -58,35 +59,35 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
if (download) {
return json({ error: 'metadata.json cannot be downloaded raw; use the snapshot metadata endpoint (secrets are redacted there)' }, { status: 403 });
}
try {
// Job-polling: `restic dump` behind a reverse proxy would abort at ~15s.
return jobResult(request, async () => {
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 });
}
if (!layout) return { error: 'metadata unreadable' };
return { content: JSON.stringify(redactSnapshotLayout(layout), null, 2) };
});
}
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
try {
if (download && isDir) {
// Binary tar stream — serve the raw bytes untouched (a UTF-8 round-trip
// would corrupt any non-ASCII byte in the archive).
const tarData = await dumpSnapshotArchive(destinationId, snapshotId, path);
const filename = sanitizeFilename((path.split('/').filter(Boolean).pop() || 'archive') + '.tar');
return new Response(new Uint8Array(tarData), {
headers: {
'Content-Type': 'application/x-tar',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
}
if (download) {
// Binary downloads are window.open navigations, not fetch()es — they can't poll a job,
// so they stay synchronous. (A raw byte/tar stream also can't be JSON-wrapped.) These
// stream restic's output, so the proxy sees bytes flowing and won't idle-abort them.
if (download) {
try {
if (isDir) {
// Binary tar stream — serve the raw bytes untouched (a UTF-8 round-trip
// would corrupt any non-ASCII byte in the archive).
const tarData = await dumpSnapshotArchive(destinationId, snapshotId, path);
const filename = sanitizeFilename((path.split('/').filter(Boolean).pop() || 'archive') + '.tar');
return new Response(new Uint8Array(tarData), {
headers: {
'Content-Type': 'application/x-tar',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
}
// A file download may be binary — serve raw bytes, not a decoded string.
const bytes = await dumpSnapshotFileBytes(destinationId, snapshotId, path);
const filename = sanitizeFilename(path.split('/').pop() || 'file');
@@ -96,13 +97,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
'Content-Disposition': `attachment; filename="${filename}"`
}
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
// Inline preview — text only.
const content = await dumpSnapshotFile(destinationId, snapshotId, path);
return json({ content });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
// Inline preview (text only) — job-polling: `restic dump` would otherwise be aborted
// at the reverse-proxy's ~15s cap.
return jobResult(request, async () => {
const content = await dumpSnapshotFile(destinationId, snapshotId, path);
return { content };
});
};
@@ -6,8 +6,9 @@ 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';
import { jobResult } from '$lib/server/sse';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
@@ -26,14 +27,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
if (envDenied) return envDenied;
try {
// Job-polling: reading metadata runs `restic dump` which a proxy would abort at ~15s.
return jobResult(request, async () => {
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
if (!metadata) return { error: 'No metadata available' };
// 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 });
}
return redactSnapshotLayout(metadata);
});
};
@@ -5,8 +5,9 @@ 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';
import { jobResult } from '$lib/server/sse';
export const GET: RequestHandler = async ({ url, cookies }) => {
export const GET: RequestHandler = async ({ url, cookies, request }) => {
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
@@ -31,11 +32,6 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const deniedB = await guardSnapshotEnvAccess(auth, destinationId, snapB);
if (deniedB) return deniedB;
try {
const result = await diffSnapshots(destinationId, snapA, snapB);
return json(result);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return json({ error: msg }, { status: 500 });
}
// Job-polling: `restic diff` runs two restic reads a proxy would abort at ~15s.
return jobResult(request, () => diffSnapshots(destinationId, snapA, snapB));
};