1.0.38
This commit is contained in:
177 files changed
+21182
-1042
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import {
|
||||
getBackupConfigs,
|
||||
createBackupConfig,
|
||||
getBackupDestination,
|
||||
getEnvironment
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, isValidCron } from '$lib/server/scheduler';
|
||||
import { assertStackBackupable } from '$lib/server/backups';
|
||||
import { validateRetention, retentionToStore } from '$lib/server/backups/helpers';
|
||||
|
||||
// Inlined to avoid pulling lucide-svelte / svelte runtime into the server bundle
|
||||
// via $lib/utils/backup. Keep in sync with that module if the predicates change.
|
||||
function isLocalRepo(repository: string): boolean {
|
||||
return repository.startsWith('/') || repository.startsWith('./');
|
||||
}
|
||||
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
|
||||
if (!connectionType) return false;
|
||||
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
|
||||
if (connectionType === 'direct' && !!host) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
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 type = url.searchParams.get('type') ?? undefined;
|
||||
const targetName = url.searchParams.get('target') ?? undefined;
|
||||
const envParam = url.searchParams.get('env');
|
||||
const environmentId = envParam ? parseInt(envParam) : undefined;
|
||||
|
||||
let configs = await getBackupConfigs({
|
||||
type,
|
||||
targetName,
|
||||
environmentId
|
||||
});
|
||||
|
||||
// (audit #40) Enterprise env scoping — the LIST read path never filtered by
|
||||
// accessible environments, leaking configs (targetName, destination, schedule,
|
||||
// status) for envs the caller can't access. getAccessibleEnvironmentIds()
|
||||
// returns null when the caller can see everything.
|
||||
if (auth.isEnterprise) {
|
||||
const allowed = await auth.getAccessibleEnvironmentIds();
|
||||
if (allowed !== null) {
|
||||
const allowedSet = new Set(allowed);
|
||||
configs = configs.filter((c: any) => c.environmentId == null || allowedSet.has(c.environmentId));
|
||||
}
|
||||
}
|
||||
|
||||
return json(configs);
|
||||
};
|
||||
|
||||
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 body = await request.json();
|
||||
|
||||
if (!body.destinationId || !body.targetName) {
|
||||
return json({ error: 'Missing required fields: destinationId, targetName' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Light allowlist on targetName — it flows into paths/restic tags. Complements
|
||||
// the stack-path traversal guard (audit #43).
|
||||
if (typeof body.targetName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(body.targetName)) {
|
||||
return json({ error: 'Invalid targetName' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate cron schedule if provided (audit #7)
|
||||
if (typeof body.schedule === 'string' && body.schedule.trim() && !isValidCron(body.schedule.trim())) {
|
||||
return json({ error: `Invalid cron expression: ${body.schedule}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate retention keep-* values before persisting (audit medium #13) — reject
|
||||
// negative/fractional/string/out-of-range so corrupt retention can't reach restic.
|
||||
const retentionCheck = validateRetention(body.retention);
|
||||
if (!retentionCheck.ok) {
|
||||
return json({ error: `Invalid retention: ${retentionCheck.reason}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Environment access check (enterprise RBAC)
|
||||
if (body.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(body.environmentId)) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Refuse local repo + remote env: the restic helper runs on the REMOTE
|
||||
// daemon, which has no access to Dockhand's filesystem. The backup would
|
||||
// silently write to a wrong path on the remote host.
|
||||
if (body.environmentId) {
|
||||
const [dest, env] = await Promise.all([
|
||||
getBackupDestination(body.destinationId),
|
||||
getEnvironment(body.environmentId)
|
||||
]);
|
||||
if (dest && env && isRemoteEnv(env.connectionType, env.host) && isLocalRepo(dest.repository)) {
|
||||
return json({
|
||||
error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// External stacks (no known compose path) can't be backed up usefully —
|
||||
// see assertStackBackupable for the why.
|
||||
const stackCheck = await assertStackBackupable(body.type || 'container', body.targetName, body.environmentId);
|
||||
if (!stackCheck.ok) {
|
||||
return json({ error: stackCheck.reason }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await createBackupConfig({
|
||||
type: body.type || 'container',
|
||||
targetName: body.targetName,
|
||||
environmentId: body.environmentId ?? null,
|
||||
destinationId: body.destinationId,
|
||||
enabled: body.enabled !== false,
|
||||
allVolumes: body.allVolumes ?? true,
|
||||
selectedVolumes: body.selectedVolumes ? JSON.stringify(body.selectedVolumes) : null,
|
||||
stopBeforeBackup: body.stopBeforeBackup ?? false,
|
||||
schedule: body.schedule ?? null,
|
||||
retention: retentionToStore(body.retention, body.schedule),
|
||||
options: body.options ? JSON.stringify(body.options) : null,
|
||||
tags: body.tags ? JSON.stringify(body.tags) : null
|
||||
});
|
||||
|
||||
// Register schedule if enabled and has a cron expression
|
||||
if (config.enabled && config.schedule) {
|
||||
await registerSchedule(config.id, 'backup', config.environmentId);
|
||||
}
|
||||
|
||||
await auditBackup(event, 'create', body.targetName, body.environmentId, { destinationId: body.destinationId, schedule: body.schedule });
|
||||
return json(config, { status: 201 });
|
||||
} catch (error: any) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import {
|
||||
updateBackupConfig,
|
||||
deleteBackupConfig,
|
||||
getBackupDestination,
|
||||
getEnvironment
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule, isValidCron } from '$lib/server/scheduler';
|
||||
import { isBackupRunning } from '$lib/server/backups';
|
||||
import { validateRetention, retentionToStore, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers';
|
||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||
import { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
// (audit LOW #41) Env-scope the single-config read too — the LIST endpoint
|
||||
// already filters, but this direct fetch leaked a config for an off-limits env.
|
||||
const gated = await loadConfigGateEnv(params.id, auth);
|
||||
if ('response' in gated) return gated.response;
|
||||
|
||||
return json(gated.config);
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
// Environment access check (enterprise RBAC). Config's env is fixed at creation.
|
||||
const gated = await loadConfigGateEnv(params.id, auth);
|
||||
if ('response' in gated) return gated.response;
|
||||
const existing = gated.config;
|
||||
const id = existing.id;
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate cron schedule if provided (audit #7)
|
||||
if (typeof body.schedule === 'string' && body.schedule.trim() && !isValidCron(body.schedule.trim())) {
|
||||
return json({ error: `Invalid cron expression: ${body.schedule}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate retention keep-* values before persisting (audit medium #13).
|
||||
const retentionCheck = validateRetention(body.retention);
|
||||
if (!retentionCheck.ok) {
|
||||
return json({ error: `Invalid retention: ${retentionCheck.reason}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// (audit #56) Changing the destination while a backup is in flight would leave
|
||||
// the running backup on the OLD destination's lock while the schedule re-arms
|
||||
// under the new one — refuse the destination change until the run finishes.
|
||||
if (body.destinationId !== undefined && body.destinationId !== existing.destinationId && isBackupRunning(id)) {
|
||||
return json({ error: 'Cannot change the destination while a backup is running for this config' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Refuse local repo + remote env (same rule as POST). Effective destination
|
||||
// is the body's value if supplied, otherwise the existing config's.
|
||||
const effectiveDestId = body.destinationId ?? existing.destinationId;
|
||||
const effectiveEnvId = existing.environmentId; // env is fixed at creation
|
||||
if (effectiveEnvId) {
|
||||
const [dest, env] = await Promise.all([
|
||||
getBackupDestination(effectiveDestId),
|
||||
getEnvironment(effectiveEnvId)
|
||||
]);
|
||||
if (dest && env && isRemoteEnvironment(env) && isLocalRepo(dest.repository)) {
|
||||
return json({
|
||||
error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-enable a config that transitions from manual (no schedule) to scheduled
|
||||
// (a real cron) — otherwise a paused run-once config that the user edits to add a
|
||||
// schedule stays paused, forcing a manual un-pause. See resolveEnabledOnScheduleChange.
|
||||
const resolvedEnabled = resolveEnabledOnScheduleChange({
|
||||
requestedEnabled: body.enabled,
|
||||
existingSchedule: existing.schedule,
|
||||
newSchedule: body.schedule ?? existing.schedule
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = await updateBackupConfig(id, {
|
||||
destinationId: body.destinationId,
|
||||
enabled: resolvedEnabled,
|
||||
allVolumes: body.allVolumes,
|
||||
selectedVolumes: body.selectedVolumes ? JSON.stringify(body.selectedVolumes) : body.selectedVolumes,
|
||||
stopBeforeBackup: body.stopBeforeBackup,
|
||||
schedule: body.schedule,
|
||||
// Apply the default scheduled retention the same way create does, so a
|
||||
// config edited to add a schedule with no explicit retention doesn't end
|
||||
// up with pruning disabled and an unbounded-growth repo. Uses the effective
|
||||
// schedule (the incoming one, or the existing one if unchanged).
|
||||
retention: retentionToStore(body.retention, body.schedule ?? existing.schedule),
|
||||
options: body.options ? JSON.stringify(body.options) : body.options,
|
||||
tags: body.tags ? JSON.stringify(body.tags) : body.tags
|
||||
});
|
||||
|
||||
if (!updated) return json({ error: 'Update failed' }, { status: 500 });
|
||||
|
||||
// Update schedule registration
|
||||
if (updated.enabled && updated.schedule) {
|
||||
await registerSchedule(updated.id, 'backup', updated.environmentId);
|
||||
} else {
|
||||
unregisterSchedule(updated.id, 'backup');
|
||||
}
|
||||
|
||||
await auditBackup(event, 'update', updated.targetName, updated.environmentId, { configId: id });
|
||||
return json(updated);
|
||||
} catch (error: any) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
// Environment access check (enterprise RBAC). Config's env is fixed at creation.
|
||||
const gated = await loadConfigGateEnv(params.id, auth);
|
||||
if ('response' in gated) return gated.response;
|
||||
const existing = gated.config;
|
||||
const id = existing.id;
|
||||
|
||||
// (audit #55) Don't delete a config out from under an in-flight backup — the
|
||||
// run's helper containers + lock reference it; deleting mid-run is messy.
|
||||
if (isBackupRunning(id)) {
|
||||
return json({ error: 'A backup is currently running for this config — stop it before deleting' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Unregister schedule before deleting
|
||||
unregisterSchedule(id, 'backup');
|
||||
|
||||
await deleteBackupConfig(id);
|
||||
await auditBackup(event, 'delete', existing.targetName, existing.environmentId, { configId: id });
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { runBackup } from '$lib/server/backups';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
const gated = await loadConfigGateEnv(params.id, auth);
|
||||
if ('response' in gated) return gated.response;
|
||||
const config = gated.config;
|
||||
|
||||
await auditBackup(event, 'backup', config.targetName, config.environmentId, { configId: config.id });
|
||||
|
||||
return createJobResponse(async (send) => {
|
||||
const result = await runBackup(config.id, 'manual', (status, message) => {
|
||||
send('progress', { status, message });
|
||||
});
|
||||
|
||||
send('result', result);
|
||||
|
||||
// A backup succeeds as 'success' or 'warning' (partial read still committed);
|
||||
// 'skipped' isn't a failure. Only a real 'error' throws.
|
||||
if (result.status === 'error') {
|
||||
throw new Error(result.error || 'Backup failed');
|
||||
}
|
||||
}, request);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import { cancelBackup } from '$lib/server/backups';
|
||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
// (bug #12) Enforce per-env access, which this route previously OMITTED — a
|
||||
// user with global backups:manage but no role on the config's env could
|
||||
// cancel an in-flight backup there. loadConfigGateEnv can't be called without
|
||||
// the env gate, so the omission is now unrepresentable.
|
||||
const gated = await loadConfigGateEnv(params.id, auth);
|
||||
if ('response' in gated) return gated.response;
|
||||
const cfg = gated.config;
|
||||
|
||||
try {
|
||||
const stopped = await cancelBackup(cfg.id);
|
||||
// 'stop' is the canonical audit action for "abort running operation"
|
||||
await auditBackup(event, 'stop', cfg.targetName, cfg.environmentId, { configId: cfg.id, killed: stopped });
|
||||
return json({ success: true, stopped });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: msg }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import {
|
||||
getBackupDestinations,
|
||||
createBackupDestination,
|
||||
decryptBackupDestination,
|
||||
updateBackupDestination,
|
||||
updateBackupDestinationTestStatus
|
||||
} from '$lib/server/db';
|
||||
import { initRepository, testRepository } from '$lib/server/backups';
|
||||
import { registerSchedule } from '$lib/server/scheduler';
|
||||
import { validateRepositoryForSave, validateFlags, validatePolicySchedules } from '$lib/server/backups/helpers';
|
||||
|
||||
/**
|
||||
* Prepare destination for API response — strip password, parse env vars.
|
||||
*
|
||||
* The LIST endpoint omits envVars entirely. Cloud-credential env vars
|
||||
* (AWS_SECRET_ACCESS_KEY, AZURE_ACCOUNT_KEY, etc.) used to ship decrypted to
|
||||
* any user with backups:view permission, even though the LIST view doesn't
|
||||
* need them. The edit modal re-fetches via GET /destinations/[id] (which
|
||||
* still returns envVars decrypted so the form can pre-fill credential
|
||||
* fields). Single GET is what populates the modal; LIST never needs them.
|
||||
*/
|
||||
function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any {
|
||||
const decrypted = decryptBackupDestination(dest);
|
||||
const result = { ...dest };
|
||||
delete result.password;
|
||||
if (opts.includeEnvVars) {
|
||||
result.envVars = decrypted.decryptedEnvVars;
|
||||
} else {
|
||||
delete result.envVars;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const destinations = await getBackupDestinations();
|
||||
// LIST: strip envVars (cloud creds). Modal re-fetches single destination to edit.
|
||||
return json(destinations.map(d => prepareDestination(d, { includeEnvVars: false })));
|
||||
};
|
||||
|
||||
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 body = await request.json();
|
||||
|
||||
if (!body.name || !body.repository || !body.password) {
|
||||
return json({ error: 'Missing required fields: name, repository, password' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate repository scheme + SSRF host (audit #7/#53) and restic flags (#7)
|
||||
// BEFORE persisting, so nothing is saved on bad input.
|
||||
const repoError = validateRepositoryForSave(body.repository);
|
||||
if (repoError) return json({ error: repoError }, { status: 400 });
|
||||
const flagError = validateFlags(body.flags);
|
||||
if (flagError) return json({ error: flagError }, { status: 400 });
|
||||
|
||||
// Validate any cron schedules in the supplied policies (audit #7)
|
||||
const policyCronError = validatePolicySchedules(body.policies);
|
||||
if (policyCronError) {
|
||||
return json({ error: policyCronError }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Set default policies if not provided
|
||||
const defaultPolicies = JSON.stringify({
|
||||
pruneEnabled: true, pruneSchedule: '0 0 1 * *', pruneMaxUnused: '10',
|
||||
checkEnabled: true, checkSchedule: '0 0 1 * *',
|
||||
verifyEnabled: false, verifySchedule: '0 0 1 * *', verifyDataSubset: '5%',
|
||||
autoUnlock: true
|
||||
});
|
||||
const destination = await createBackupDestination({
|
||||
name: body.name,
|
||||
repository: body.repository,
|
||||
password: body.password,
|
||||
envVars: body.envVars ? JSON.stringify(body.envVars) : null,
|
||||
flags: body.flags ?? null,
|
||||
hostPath: body.hostPath ?? null,
|
||||
policies: body.policies ?? defaultPolicies
|
||||
});
|
||||
|
||||
// Auto-initialize the repository, then TEST it before recording success.
|
||||
// `restic init` succeeds on an already-existing repo without decrypting it,
|
||||
// so init alone doesn't prove the stored password is correct. testRepository
|
||||
// reads the repo config (which requires the password), so a wrong password
|
||||
// against a pre-existing repo is recorded as failed instead of success.
|
||||
try {
|
||||
await initRepository(destination.id);
|
||||
const test = await testRepository(destination.id);
|
||||
if (test.ok) {
|
||||
await updateBackupDestination(destination.id, {
|
||||
lastTestStatus: 'success',
|
||||
lastTestAt: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
await updateBackupDestinationTestStatus(destination.id, 'failed', test.error ?? 'Repository is not reachable or the password is incorrect');
|
||||
}
|
||||
} catch (initErr) {
|
||||
// Init failed — destination saved but not initialized. Record the
|
||||
// failure on the destination so the UI reflects it (audit #9).
|
||||
const initMsg = initErr instanceof Error ? initErr.message : String(initErr);
|
||||
await updateBackupDestinationTestStatus(destination.id, 'failed', initMsg);
|
||||
}
|
||||
|
||||
// Register repo maintenance schedules from default policies
|
||||
try {
|
||||
await registerSchedule(destination.id, 'repo_prune', null);
|
||||
await registerSchedule(destination.id, 'repo_check', null);
|
||||
await registerSchedule(destination.id, 'repo_verify', null);
|
||||
} catch {}
|
||||
|
||||
await auditBackupDestination(event, 'create', destination.id, destination.name, { repository: body.repository });
|
||||
// POST returns envVars — user just provided them, no point re-hiding.
|
||||
return json(prepareDestination(destination, { includeEnvVars: true }), { status: 201 });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('UNIQUE constraint')) {
|
||||
return json({ error: 'A destination with this name already exists' }, { status: 409 });
|
||||
}
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import {
|
||||
getBackupDestination,
|
||||
updateBackupDestination,
|
||||
deleteBackupDestination,
|
||||
decryptBackupDestination,
|
||||
getBackupConfigs,
|
||||
getEnvironment
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { validatePolicySchedules, validateRepositoryForSave, validateFlags } from '$lib/server/backups/helpers';
|
||||
import { destinationHasRunningBackup } from '$lib/server/backups';
|
||||
|
||||
// Inlined predicates (kept in sync with the config routes) — see
|
||||
// configs/+server.ts. A local repo cannot back up a remote environment.
|
||||
function isLocalRepo(repository: string): boolean {
|
||||
return repository.startsWith('/') || repository.startsWith('./');
|
||||
}
|
||||
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
|
||||
if (!connectionType) return false;
|
||||
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
|
||||
if (connectionType === 'direct' && !!host) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-destination response shape. envVars (decrypted cloud credentials) are
|
||||
* ONLY included for callers with backups:manage — the edit form that pre-fills
|
||||
* credential fields. A backups:view (Viewer) caller must never receive them
|
||||
* (audit #26/#29). The password is always stripped. The LIST endpoint omits
|
||||
* envVars entirely — see /api/backup/destinations/+server.ts.
|
||||
*/
|
||||
function prepareDestination(dest: any, includeSecrets: boolean): any {
|
||||
const result = { ...dest };
|
||||
delete result.password;
|
||||
if (includeSecrets) {
|
||||
result.envVars = decryptBackupDestination(dest).decryptedEnvVars;
|
||||
} else {
|
||||
delete result.envVars;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
const destination = await getBackupDestination(id);
|
||||
if (!destination) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
|
||||
// Only surface decrypted cloud credentials to users who can edit the
|
||||
// destination; Viewers get the row without secrets.
|
||||
const canManage = !auth.authEnabled || await auth.can('backups', 'manage');
|
||||
return json(prepareDestination(destination, canManage));
|
||||
};
|
||||
|
||||
export const PUT: 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 id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
const existing = await getBackupDestination(id);
|
||||
if (!existing) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Changing the repo target or password while a backup is writing to this
|
||||
// destination would break the in-flight run. A policy/name-only edit is safe.
|
||||
if ((body.repository !== undefined || body.password !== undefined) && await destinationHasRunningBackup(id)) {
|
||||
return json({ error: 'A backup using this destination is currently running. Try again once it finishes.' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Validate any cron schedules in the supplied policies (audit #7)
|
||||
const policyCronError = validatePolicySchedules(body.policies);
|
||||
if (policyCronError) {
|
||||
return json({ error: policyCronError }, { status: 400 });
|
||||
}
|
||||
|
||||
// (audit #7/#53) The PUT edit path previously never re-validated: validate the
|
||||
// repository scheme/SSRF host and restic flags when supplied, before persisting.
|
||||
if (body.repository !== undefined) {
|
||||
const repoError = validateRepositoryForSave(body.repository);
|
||||
if (repoError) return json({ error: repoError }, { status: 400 });
|
||||
}
|
||||
const flagError = validateFlags(body.flags);
|
||||
if (flagError) return json({ error: flagError }, { status: 400 });
|
||||
|
||||
// (audit #8) Changing the repository to a LOCAL path bypasses the config-route
|
||||
// guard: refuse if any config on this destination targets a remote environment
|
||||
// (the restic helper runs on the remote daemon, which can't see Dockhand's fs).
|
||||
const effectiveRepository = body.repository !== undefined ? body.repository : existing.repository;
|
||||
if (body.repository !== undefined && isLocalRepo(effectiveRepository)) {
|
||||
const configs = await getBackupConfigs();
|
||||
const envIds = [...new Set(
|
||||
configs.filter((c: any) => c.destinationId === id && c.environmentId).map((c: any) => c.environmentId as number)
|
||||
)];
|
||||
const envs = await Promise.all(envIds.map((eid) => getEnvironment(eid)));
|
||||
const remote = envs.find((env) => env && isRemoteEnv(env.connectionType, env.host));
|
||||
if (remote) {
|
||||
return json({
|
||||
error: `Local repository cannot back up containers on remote environment "${remote.name}". Use S3, REST, or another non-local backend.`
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateBackupDestination(id, {
|
||||
name: body.name,
|
||||
repository: body.repository,
|
||||
password: body.password,
|
||||
envVars: body.envVars !== undefined ? JSON.stringify(body.envVars) : undefined,
|
||||
flags: body.flags,
|
||||
hostPath: body.hostPath,
|
||||
policies: body.policies
|
||||
});
|
||||
if (!updated) return json({ error: 'Update failed' }, { status: 500 });
|
||||
|
||||
// Re-register maintenance schedules only when the policies actually changed
|
||||
// (audit #22) — a metadata-only edit (rename, flags, etc.) shouldn't churn
|
||||
// the repo_prune/check/verify cron jobs.
|
||||
if (body.policies !== undefined) {
|
||||
try {
|
||||
await registerSchedule(id, 'repo_prune', null);
|
||||
await registerSchedule(id, 'repo_check', null);
|
||||
await registerSchedule(id, 'repo_verify', null);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Audit: don't log the new password — only the fact that it changed
|
||||
await auditBackupDestination(event, 'update', id, updated.name, {
|
||||
repositoryChanged: body.repository !== undefined && body.repository !== existing.repository,
|
||||
passwordChanged: body.password !== undefined,
|
||||
policiesChanged: body.policies !== undefined
|
||||
});
|
||||
|
||||
// PUT caller already holds backups:manage — safe to echo secrets back.
|
||||
return json(prepareDestination(updated, true));
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('UNIQUE constraint')) {
|
||||
return json({ error: 'A destination with this name already exists' }, { status: 409 });
|
||||
}
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: 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 id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
const existing = await getBackupDestination(id);
|
||||
if (!existing) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
|
||||
// Don't delete a destination out from under a backup that's writing to its repo.
|
||||
if (await destinationHasRunningBackup(id)) {
|
||||
return json({ error: 'A backup using this destination is currently running. Try again once it finishes.' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Unregister all maintenance schedules for this destination
|
||||
unregisterSchedule(id, 'repo_prune');
|
||||
unregisterSchedule(id, 'repo_check');
|
||||
unregisterSchedule(id, 'repo_verify');
|
||||
|
||||
// Tear down in-memory croner jobs for any backup configs that reference this
|
||||
// destination (audit #47). The config rows may cascade-delete, but the croner
|
||||
// jobs would otherwise stay registered as orphans. Mirrors env-delete teardown.
|
||||
try {
|
||||
const allConfigs = await getBackupConfigs();
|
||||
for (const config of allConfigs) {
|
||||
if (config.destinationId === id) {
|
||||
unregisterSchedule(config.id, 'backup');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to unregister backup schedules for destination "${existing.name}":`, err);
|
||||
}
|
||||
|
||||
await deleteBackupDestination(id);
|
||||
await auditBackupDestination(event, 'delete', id, existing.name, { repository: existing.repository });
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
|
||||
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 id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
const dest = await getBackupDestination(id);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
|
||||
try {
|
||||
await initRepository(id);
|
||||
await updateBackupDestinationTestStatus(id, 'success');
|
||||
// Repo init is a state change on the destination, treat it as an
|
||||
// 'update' so it's distinguishable from create-destination events.
|
||||
await auditBackupDestination(event, 'update', id, dest.name, { action: 'init', status: 'success' });
|
||||
return json({ success: true, message: 'Repository initialized' });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
await updateBackupDestinationTestStatus(id, 'failed', msg);
|
||||
await auditBackupDestination(event, 'update', id, dest.name, { action: 'init', status: 'failed', error: msg });
|
||||
return json({ success: false, error: msg }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination } from '$lib/server/db';
|
||||
import { rotateDestinationPassword } from '$lib/server/backups';
|
||||
|
||||
/**
|
||||
* Rotate the restic repository password for a destination.
|
||||
*
|
||||
* Body: { currentPassword: string, newPassword: string }
|
||||
*
|
||||
* 200 — rotated, DB updated
|
||||
* 400 — input invalid or current password wrong
|
||||
* 404 — destination not found
|
||||
* 409 — restic rotated but DB write failed (manual recovery needed; details
|
||||
* include dbOutOfSync: true)
|
||||
* 500 — restic call failed for an unrelated reason
|
||||
*/
|
||||
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 id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : '';
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '';
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return json({ error: 'currentPassword and newPassword are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dest = await getBackupDestination(id);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
|
||||
const result = await rotateDestinationPassword(id, currentPassword, newPassword);
|
||||
if (!result.ok) {
|
||||
// Audit the failed attempt — useful signal for spotting wrong-password
|
||||
// probes against destinations.
|
||||
await auditBackupDestination(event, 'update', id, dest.name, {
|
||||
action: 'rotate-key',
|
||||
success: false,
|
||||
error: result.error,
|
||||
dbOutOfSync: result.dbOutOfSync ?? false
|
||||
});
|
||||
if (result.dbOutOfSync) {
|
||||
return json({ error: result.error, dbOutOfSync: true }, { status: 409 });
|
||||
}
|
||||
// Restic reports an incorrect current password as "wrong password" or
|
||||
// "no key found" — surface a clearer 400 so the UI can say the current
|
||||
// password is incorrect rather than a generic failure.
|
||||
if (/wrong password|no key found/i.test(result.error)) {
|
||||
return json({ error: 'Current password incorrect' }, { status: 400 });
|
||||
}
|
||||
if (result.error.toLowerCase().includes('does not match') || result.error.toLowerCase().includes('must')) {
|
||||
return json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return json({ error: result.error }, { status: 500 });
|
||||
}
|
||||
|
||||
await auditBackupDestination(event, 'update', id, dest.name, { action: 'rotate-key', success: true });
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination, getBackupConfigs } from '$lib/server/db';
|
||||
import { runRepoTask } 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 destId = parseInt(params.id);
|
||||
if (isNaN(destId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
|
||||
|
||||
const body = await request.json();
|
||||
const task = body.task as string;
|
||||
|
||||
if (!['unlock', 'check', 'prune', 'stats', 'repair-index', 'repair-snapshots'].includes(task)) {
|
||||
return json({ error: `Invalid task: ${task}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const dest = await getBackupDestination(destId);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
|
||||
// Repo maintenance (prune/check/repair) is destructive and operates on the
|
||||
// shared repo. Destinations aren't environment-scoped, so gate on every
|
||||
// environment whose configs use this destination: an enterprise user scoped
|
||||
// to one env must not run maintenance on a repo shared with another env's data.
|
||||
if (auth.isEnterprise) {
|
||||
const configs = await getBackupConfigs();
|
||||
const envIds = [...new Set(
|
||||
configs.filter((c: any) => c.destinationId === destId && c.environmentId).map((c: any) => c.environmentId as number)
|
||||
)];
|
||||
for (const envId of envIds) {
|
||||
if (!await auth.canAccessEnvironment(envId)) {
|
||||
return json({ error: 'Access denied to an environment using this destination' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// `task` is validated against the allowed list above.
|
||||
const result = await runRepoTask(destId, task as import('$lib/server/backups').RepoTask);
|
||||
// 'prune' and 'check' map to canonical audit actions; the rest land on
|
||||
// 'update' so they're still recoverable from the log.
|
||||
const action = task === 'prune' ? 'prune' : task === 'check' ? 'verify' : 'update';
|
||||
// runRepoTask can resolve with { success: false, error } WITHOUT throwing
|
||||
// (e.g. repo unreachable / needs init). Derive the audited success and
|
||||
// error from the result rather than hardcoding success (audit #53).
|
||||
await auditBackupDestination(event, action, destId, dest.name, { task, success: result.success, error: result.error });
|
||||
return json(result, { status: result.success ? 200 : 500 });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
await auditBackupDestination(event, 'update', destId, dest.name, { task, success: false, error: msg });
|
||||
return json({ error: msg }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import {
|
||||
getBackupDestination,
|
||||
updateBackupDestinationTestStatus
|
||||
} from '$lib/server/db';
|
||||
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 id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
const destination = await getBackupDestination(id);
|
||||
if (!destination) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
|
||||
const result = await testRepository(id);
|
||||
if (result.ok) {
|
||||
await updateBackupDestinationTestStatus(id, 'success');
|
||||
return json({ success: true, status: 'success' });
|
||||
}
|
||||
if (result.needsInit) {
|
||||
await updateBackupDestinationTestStatus(id, 'needs_init', result.error);
|
||||
return json({ success: false, status: 'needs_init', error: result.error });
|
||||
}
|
||||
await updateBackupDestinationTestStatus(id, 'failed', result.error);
|
||||
return json({ success: false, status: 'failed', error: result.error });
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination } from '$lib/server/db';
|
||||
import { verifyBackup } from '$lib/server/backups';
|
||||
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 destinationId = parseInt(params.id);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const dataSubset = body.dataSubset || '5%';
|
||||
|
||||
const dest = await getBackupDestination(destinationId);
|
||||
if (dest) {
|
||||
await auditBackupDestination(event, 'verify', destinationId, dest.name, { dataSubset });
|
||||
}
|
||||
|
||||
return createJobResponse(async (send) => {
|
||||
send('progress', { message: `Verifying backup integrity (reading ${dataSubset} of data)...` });
|
||||
|
||||
const result = await verifyBackup(destinationId, {
|
||||
dataSubset,
|
||||
onProgress: (message) => {
|
||||
send('progress', { message });
|
||||
}
|
||||
});
|
||||
|
||||
send('result', result);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Verification failed');
|
||||
}
|
||||
}, request);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getBackupDestination, decryptBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
|
||||
import { spawn } from 'child_process';
|
||||
import { buildResticEnv, cleanErrorMsg, isRepoNotInitializedError, RESTIC_EXIT_REPO_NOT_FOUND, validateRepositoryForSave } from '$lib/server/backups/helpers';
|
||||
|
||||
/**
|
||||
* Test a backup destination configuration.
|
||||
* - With `destinationId`: uses saved credentials from DB
|
||||
* - Without: uses inline credentials from request body (for testing before save)
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
let repository: string;
|
||||
let password: string;
|
||||
let envVars: Record<string, string> = {};
|
||||
|
||||
if (body.destinationId) {
|
||||
// Use saved destination from DB
|
||||
const dest = await getBackupDestination(body.destinationId);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
const decrypted = decryptBackupDestination(dest);
|
||||
repository = dest.repository;
|
||||
password = decrypted.decryptedPassword;
|
||||
envVars = decrypted.decryptedEnvVars;
|
||||
} else {
|
||||
// Inline test (before save)
|
||||
if (!body.repository || !body.password) {
|
||||
return json({ error: 'Repository and password are required' }, { status: 400 });
|
||||
}
|
||||
// Reject unsupported backends AND SSRF-dangerous hosts (loopback/metadata)
|
||||
// before spawning restic — same guard the save path uses, so an inline test
|
||||
// can't probe an internal host that a saved destination couldn't reach.
|
||||
const repoError = validateRepositoryForSave(body.repository);
|
||||
if (repoError) {
|
||||
return json({ error: repoError }, { status: 400 });
|
||||
}
|
||||
repository = body.repository;
|
||||
password = body.password;
|
||||
if (body.envVars && typeof body.envVars === 'object') {
|
||||
for (const [key, value] of Object.entries(body.envVars)) {
|
||||
if (typeof value === 'string') envVars[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = buildResticEnv(process.env, { repository, password, envVars });
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
|
||||
const proc = spawn('restic', ['snapshots', '--json', '--no-lock', '--latest', '1'], { env, timeout: 30000 });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (d) => { stdout += d; });
|
||||
proc.stderr.on('data', (d) => { stderr += d; });
|
||||
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
proc.on('error', (err) => resolve({ code: 1, stdout: '', stderr: err.message }));
|
||||
});
|
||||
|
||||
if (result.code === 0) {
|
||||
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'success');
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
// Detect the repo-not-initialized case (audit #24): restic exits 10, or
|
||||
// stderr matches the "no repository" family of messages. Return a distinct,
|
||||
// friendlier status so the UI can offer to initialize.
|
||||
const rawStderr = result.stderr.trim();
|
||||
const notInitialized =
|
||||
result.code === RESTIC_EXIT_REPO_NOT_FOUND ||
|
||||
isRepoNotInitializedError({ exitCode: result.code }) ||
|
||||
/unable to open (config|repository)|Is there a repository/i.test(rawStderr);
|
||||
|
||||
if (notInitialized) {
|
||||
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'needs_init', 'Repository not initialized');
|
||||
return json({ success: false, needsInit: true, error: 'Repository not initialized' });
|
||||
}
|
||||
|
||||
// Surface the real restic error (audit #8): derive detail from stderr,
|
||||
// cleaned of embedded JSON/ANSI, falling back to the exit code.
|
||||
const detail = rawStderr ? cleanErrorMsg(rawStderr) : `restic exited with code ${result.code}`;
|
||||
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'failed', detail);
|
||||
return json({ success: false, error: detail });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'failed', errorMsg);
|
||||
return json({ success: false, error: errorMsg });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { runRestore } from '$lib/server/backups';
|
||||
import { validateRestoreRequest, type RestoreMode } from '$lib/server/backups/validate';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import { auditRestore } from '$lib/server/audit';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
|
||||
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 body = await request.json();
|
||||
const mode: RestoreMode = body.mode === 'in-place' ? 'in-place' : 'new-location';
|
||||
// Optional post-restore action. In-place: start/recreate/redeploy/none.
|
||||
// New-location clone: recreate/redeploy/none bring the target up on the target env.
|
||||
const postRestore = ['start', 'recreate', 'redeploy', 'none'].includes(body.postRestore) ? body.postRestore : undefined;
|
||||
// New-location clone: per-volume destinations on the target env.
|
||||
const volumeDestinations = Array.isArray(body.volumeDestinations) ? body.volumeDestinations : undefined;
|
||||
|
||||
// Authorization gates run BEFORE request validation, so an unauthorized caller
|
||||
// never learns anything about the request shape.
|
||||
// Target environment access (enterprise RBAC).
|
||||
if (body.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(body.environmentId)) {
|
||||
return json({ error: 'Access denied to target environment' }, { status: 403 });
|
||||
}
|
||||
// Gate on the SNAPSHOT's owning environment too (server-resolved, fail-closed).
|
||||
const denied = await guardSnapshotEnvAccess(auth, body.destinationId, body.snapshotId);
|
||||
if (denied) return denied;
|
||||
|
||||
// Validate the request shape (all errors at once). Enforces the destructive-
|
||||
// intent rule: an in-place restore requires explicit confirmation.
|
||||
const check = validateRestoreRequest({
|
||||
destinationId: body.destinationId,
|
||||
snapshotId: body.snapshotId,
|
||||
volumes: body.volumes,
|
||||
mode,
|
||||
confirmOverwrite: body.confirmOverwrite,
|
||||
targetPath: body.targetPath,
|
||||
stackName: body.targetName,
|
||||
environmentId: body.environmentId,
|
||||
postRestore,
|
||||
targetName: body.targetName,
|
||||
volumeDestinations,
|
||||
});
|
||||
if (!check.ok) {
|
||||
return json({ error: 'Invalid restore request', issues: check.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await auditRestore(event, body.targetName || 'unknown', body.environmentId, { snapshotId: body.snapshotId, destinationId: body.destinationId, mode });
|
||||
|
||||
// The service also enforces snapshot ownership; thread the caller's access
|
||||
// context so the enterprise environment gate is applied there too.
|
||||
const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) };
|
||||
|
||||
return createJobResponse(async (send) => {
|
||||
const result = await runRestore(
|
||||
{
|
||||
destinationId: body.destinationId,
|
||||
snapshotId: body.snapshotId,
|
||||
mode,
|
||||
targetType: body.targetType === 'stack' ? 'stack' : 'container',
|
||||
volumes: body.volumes ?? [],
|
||||
environmentId: body.environmentId ?? null,
|
||||
confirmOverwrite: body.confirmOverwrite === true,
|
||||
targetPath: body.targetPath ?? null,
|
||||
targetName: body.targetName ?? null,
|
||||
postRestore,
|
||||
volumeDestinations,
|
||||
},
|
||||
access,
|
||||
// Stream progress to the client (the restore modal's log). Without this the
|
||||
// modal showed "0 lines" then jumped straight to the result — restore's
|
||||
// op.progress lines were only persisted to the DB, never streamed.
|
||||
(status, message) => send('progress', { status, message }),
|
||||
);
|
||||
|
||||
send('result', result);
|
||||
|
||||
if (result.status === 'error') {
|
||||
throw new Error(result.error || 'Restore failed');
|
||||
}
|
||||
}, request);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { previewSnapshot } from '$lib/server/backups';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.destinationId || !body.snapshotId) {
|
||||
return json({ error: 'Missing required fields: destinationId, snapshotId' }, { status: 400 });
|
||||
}
|
||||
const invalidSnap = validateSnapshotId(body.snapshotId);
|
||||
if (invalidSnap) return invalidSnap;
|
||||
|
||||
if (body.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(body.environmentId)) {
|
||||
return json({ error: 'Access denied to target environment' }, { status: 403 });
|
||||
}
|
||||
// Gate on the snapshot's owning environment (server-resolved, fail-closed).
|
||||
const denied = await guardSnapshotEnvAccess(auth, body.destinationId, body.snapshotId);
|
||||
if (denied) return denied;
|
||||
|
||||
try {
|
||||
const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) };
|
||||
const preview = await previewSnapshot(body.destinationId, body.snapshotId, access);
|
||||
return json(preview);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditRestore } from '$lib/server/audit';
|
||||
import { cancelRestore } from '$lib/server/backups';
|
||||
import { isValidSnapshotId } from '$lib/server/backups/helpers';
|
||||
|
||||
/**
|
||||
* Cancel a running restore (audit #14). Restore was previously uncancellable —
|
||||
* the helper container was never targeted by any stop path. Kills the restore
|
||||
* helper for the given snapshotId (or all restore helpers if omitted) so restic
|
||||
* exits and runRestore's failure/cleanup path runs.
|
||||
*/
|
||||
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 body = await request.json().catch(() => ({}));
|
||||
const snapshotId: string | undefined = body.snapshotId;
|
||||
// (audit #57) Validate snapshotId when supplied, matching the restore POST route.
|
||||
if (snapshotId !== undefined && !isValidSnapshotId(snapshotId)) {
|
||||
return json({ error: 'Invalid snapshotId' }, { status: 400 });
|
||||
}
|
||||
const environmentId: number | undefined =
|
||||
typeof body.environmentId === 'number' ? body.environmentId : undefined;
|
||||
|
||||
// Enforce environment-scoped access when targeting a specific env.
|
||||
if (environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(environmentId)) {
|
||||
return json({ error: 'Environment access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stopped = await cancelRestore(snapshotId, environmentId);
|
||||
await auditRestore(event, snapshotId || 'unknown', environmentId ?? null, { snapshotId, killed: stopped });
|
||||
return json({ success: true, stopped });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: msg }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getBackupConfig, getBackupDestinations } from '$lib/server/db';
|
||||
import { listSnapshots } from '$lib/server/backups';
|
||||
import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards';
|
||||
|
||||
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 configIdParam = url.searchParams.get('configId');
|
||||
const destIdParam = url.searchParams.get('destinationId');
|
||||
const allDests = url.searchParams.get('allDestinations') === 'true';
|
||||
|
||||
if (configIdParam) {
|
||||
const configId = parseInt(configIdParam);
|
||||
if (isNaN(configId)) return json({ error: 'Invalid configId' }, { status: 400 });
|
||||
const config = await getBackupConfig(configId);
|
||||
if (!config) return json({ error: 'Config not found' }, { status: 404 });
|
||||
|
||||
// (audit #41) The config is environment-scoped; enforce env access before
|
||||
// listing snapshots (ids/sizes/paths/tags reveal the env-B workload).
|
||||
if (config.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(config.environmentId)) {
|
||||
return json({ error: 'Environment access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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.
|
||||
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([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`timed out after ${PER_DEST_MS}ms`)), PER_DEST_MS)),
|
||||
]);
|
||||
const results = await Promise.allSettled(
|
||||
destinations.map(dest => withTimeout(listSnapshots(dest.id, config.id), dest.name).then(snaps =>
|
||||
snaps.map(s => ({ ...s, _destinationId: dest.id, _destinationName: dest.name }))
|
||||
))
|
||||
);
|
||||
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.
|
||||
const failed: string[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
for (const snap of result.value) {
|
||||
if (!seen.has(snap.id)) {
|
||||
seen.add(snap.id);
|
||||
allSnapshots.push(snap);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const dest = destinations[i];
|
||||
const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
failed.push(`${dest.name} (${dest.id}): ${msg}`);
|
||||
}
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
} else if (destIdParam) {
|
||||
const destinationId = parseInt(destIdParam);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
|
||||
|
||||
try {
|
||||
// No target filter — return all snapshots in this destination.
|
||||
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.
|
||||
if (auth.isEnterprise) {
|
||||
const filtered = await filterSnapshotsByEnvAccess(auth, snapshots);
|
||||
return json(filtered);
|
||||
}
|
||||
return json(snapshots);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
} else {
|
||||
return json({ error: 'configId or destinationId parameter is required' }, { status: 400 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import { getBackupDestination } from '$lib/server/db';
|
||||
import { forgetSnapshot } from '$lib/server/backups';
|
||||
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 snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
if (invalidSnap) return invalidSnap;
|
||||
|
||||
const destIdParam = url.searchParams.get('destinationId');
|
||||
if (!destIdParam) return json({ error: 'destinationId parameter is required' }, { status: 400 });
|
||||
|
||||
const destinationId = parseInt(destIdParam);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
|
||||
|
||||
const envParam = url.searchParams.get('env');
|
||||
const envId = envParam ? parseInt(envParam) : undefined;
|
||||
|
||||
// Environment access check (enterprise RBAC) — only when an env is specified.
|
||||
if (envId !== undefined && Number.isInteger(envId) && envId > 0 && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
// (re-audit H2) Server-authoritative env gate, matching the read-content routes
|
||||
// (HIGH #8): resolve the snapshot's OWNING env from its own dockhand:env tag and
|
||||
// fail closed. The caller-supplied `env` param above is not trusted for access
|
||||
// control — omitting it previously skipped the RBAC check entirely on this
|
||||
// DESTRUCTIVE (forget --prune) route.
|
||||
const denied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
|
||||
if (denied) return denied;
|
||||
|
||||
try {
|
||||
const result = await forgetSnapshot(destinationId, snapshotId);
|
||||
const dest = await getBackupDestination(destinationId);
|
||||
if (result.ok) {
|
||||
// Audit on the backup_config entity (decision: reuse existing
|
||||
// entity types rather than add a 'snapshot' one). entityName is
|
||||
// the destination, details carries the snapshot ID and dest ID.
|
||||
await auditBackup(event, 'delete', dest?.name ?? `destination#${destinationId}`, null, {
|
||||
snapshotId,
|
||||
destinationId,
|
||||
kind: 'snapshot'
|
||||
});
|
||||
return json({ success: true });
|
||||
}
|
||||
// (audit low #25) Relay the real reason AND audit the failed delete so it's
|
||||
// traceable, instead of a generic opaque 500 with no record.
|
||||
await auditBackup(event, 'delete', dest?.name ?? `destination#${destinationId}`, null, {
|
||||
snapshotId, destinationId, kind: 'snapshot', failed: true, reason: result.reason
|
||||
});
|
||||
if (result.reason === 'not-owned') {
|
||||
return json({ error: 'Snapshot not found for this installation' }, { status: 404 });
|
||||
}
|
||||
return json({ error: result.error ?? 'Failed to forget snapshot' }, { status: 500 });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
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 snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
if (invalidSnap) return invalidSnap;
|
||||
|
||||
const destIdParam = url.searchParams.get('destinationId');
|
||||
if (!destIdParam) return json({ error: 'destinationId parameter is required' }, { status: 400 });
|
||||
|
||||
const destinationId = parseInt(destIdParam);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
|
||||
|
||||
const path = url.searchParams.get('path') || '/';
|
||||
|
||||
const envParam = url.searchParams.get('env');
|
||||
const envId = envParam ? parseInt(envParam) : undefined;
|
||||
|
||||
// (HIGH #8) Server-authoritative env access: resolve the snapshot's OWNING
|
||||
// env from its tag and enforce access — the client-supplied `env` param is no
|
||||
// longer trusted as the source of truth (omitting it previously skipped the
|
||||
// check entirely). Kept below is the caller-param check as an extra early gate.
|
||||
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
// Additional check on any explicitly-supplied env param (enterprise RBAC).
|
||||
if (envId !== undefined && !isNaN(envId) && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) {
|
||||
return json({ error: 'Environment access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } 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 snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
if (invalidSnap) return invalidSnap;
|
||||
|
||||
const destIdParam = url.searchParams.get('destinationId');
|
||||
if (!destIdParam) return json({ error: 'destinationId parameter is required' }, { status: 400 });
|
||||
|
||||
const destinationId = parseInt(destIdParam);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
|
||||
|
||||
// (HIGH #8) Enforce per-environment access on the snapshot's OWNING env,
|
||||
// resolved server-side from its tag — not a caller-supplied param.
|
||||
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
if (!path) return json({ error: 'path parameter is required' }, { status: 400 });
|
||||
|
||||
const download = url.searchParams.get('download') === '1';
|
||||
const isDir = url.searchParams.get('type') === 'directory';
|
||||
|
||||
// Validate path — no traversal
|
||||
if (path.includes('..')) {
|
||||
return json({ error: 'Invalid path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Restrict dumps to the known snapshot roots so arbitrary snapshot paths can't be
|
||||
// read. Accept the root dir itself (`/volumes`, `/metadata`) AND anything inside
|
||||
// it — the previous `/volumes/` / `/metadata/` prefix check rejected downloading a
|
||||
// top-level directory (e.g. `/metadata`) with a bogus "Invalid path".
|
||||
if (
|
||||
path !== '/volumes' && path !== '/metadata' &&
|
||||
!path.startsWith('/volumes/') && !path.startsWith('/metadata/')
|
||||
) {
|
||||
return json({ error: 'Invalid path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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');
|
||||
return new Response(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getSnapshotMetadata } 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 snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
if (invalidSnap) return invalidSnap;
|
||||
|
||||
const destIdParam = url.searchParams.get('destinationId');
|
||||
if (!destIdParam) return json({ error: 'destinationId is required' }, { status: 400 });
|
||||
|
||||
const destinationId = parseInt(destIdParam);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
|
||||
|
||||
// (HIGH #8) Enforce per-environment access on the snapshot's owning env.
|
||||
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
try {
|
||||
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
|
||||
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
|
||||
return json(metadata);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: msg }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
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 destId = url.searchParams.get('destinationId');
|
||||
const snapA = url.searchParams.get('snapshotA');
|
||||
const snapB = url.searchParams.get('snapshotB');
|
||||
|
||||
if (!destId || !snapA || !snapB) {
|
||||
return json({ error: 'Missing required params: destinationId, snapshotA, snapshotB' }, { status: 400 });
|
||||
}
|
||||
|
||||
const invalidA = validateSnapshotId(snapA);
|
||||
if (invalidA) return invalidA;
|
||||
const invalidB = validateSnapshotId(snapB);
|
||||
if (invalidB) return invalidB;
|
||||
|
||||
// (HIGH #8) Enforce per-environment access on BOTH snapshots' owning env.
|
||||
const destinationId = parseInt(destId);
|
||||
const deniedA = await guardSnapshotEnvAccess(auth, destinationId, snapA);
|
||||
if (deniedA) return deniedA;
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
isDigestBasedImage,
|
||||
getImageIdByTag,
|
||||
removeTempImage,
|
||||
tagImage
|
||||
tagImage,
|
||||
inspectImage
|
||||
} from '$lib/server/docker';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
@@ -157,6 +158,17 @@ export const POST: RequestHandler = async (event) => {
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// Capture the OLD image's Env/Labels BEFORE pulling — once the tag is
|
||||
// repointed the old digest may be untagged/GC'd and un-inspectable.
|
||||
// Used by the env/label rebase in recreateContainer (#1226, #1256).
|
||||
let oldImageConfig: { Env?: string[]; Labels?: Record<string, string> } | null = null;
|
||||
try {
|
||||
const oldImg = await inspectImage(currentImageId, envIdNum) as any;
|
||||
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels };
|
||||
} catch {
|
||||
// Best-effort; rebase will fall back if unavailable.
|
||||
}
|
||||
|
||||
// Skip system containers (Dockhand, Hawser)
|
||||
const systemType = isSystemContainer(imageName);
|
||||
if (systemType) {
|
||||
@@ -459,7 +471,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
message: `Recreating ${containerName}...`
|
||||
});
|
||||
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum, logProgress, imageName);
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum, {
|
||||
log: logProgress,
|
||||
imageNameOverride: imageName,
|
||||
oldImageConfig
|
||||
});
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
|
||||
import { listContainers, pullImage, inspectContainer, inspectImage } from '$lib/server/docker';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||
@@ -63,6 +63,16 @@ export const POST: RequestHandler = async (event) => {
|
||||
const imageName = config.Image;
|
||||
const containerName = container.name;
|
||||
|
||||
// Capture the OLD image's Env/Labels BEFORE the pull for the env/label
|
||||
// rebase (#1226, #1256) — the old digest may be GC'd after the pull.
|
||||
let oldImageConfig: { Env?: string[]; Labels?: Record<string, string> } | null = null;
|
||||
try {
|
||||
const oldImg = await inspectImage(inspectData.Image, envIdNum) as any;
|
||||
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels };
|
||||
} catch {
|
||||
// Best-effort; rebase falls back if unavailable.
|
||||
}
|
||||
|
||||
// Skip containers with dockhand.update=false label
|
||||
if (isUpdateDisabledByLabel(config.Labels)) {
|
||||
results.push({
|
||||
@@ -89,7 +99,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
let newContainerId = containerId;
|
||||
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum);
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum, { oldImageConfig });
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
|
||||
@@ -12,8 +12,8 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
|
||||
|
||||
// Track startup time and initial RSS for growth rate calculation
|
||||
const startupTime = Date.now();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { join } from 'path';
|
||||
import { existsSync, rmSync, renameSync } from 'fs';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
|
||||
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs } from '$lib/server/db';
|
||||
import { clearDockerClientCache } from '$lib/server/docker';
|
||||
import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
@@ -253,6 +253,18 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
await deleteImagePruneSettings(id);
|
||||
unregisterSchedule(id, 'image_prune');
|
||||
|
||||
// Unregister backup config schedules for this environment (audit #10).
|
||||
// The config rows themselves cascade-delete with the environment, but the
|
||||
// in-memory croner jobs would otherwise stay registered as orphans.
|
||||
try {
|
||||
const envBackupConfigs = await getBackupConfigs({ environmentId: id });
|
||||
for (const config of envBackupConfigs) {
|
||||
unregisterSchedule(config.id, 'backup');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to unregister backup schedules for environment "${env.name}":`, err);
|
||||
}
|
||||
|
||||
// Clean up stack directory for this environment
|
||||
// Safety: only delete subdirectory named after the env, never the parent
|
||||
try {
|
||||
|
||||
@@ -16,8 +16,9 @@ import { registerSchedule } from '$lib/server/scheduler';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
// Stack name validation: Docker Compose requires lowercase; must start with a
|
||||
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -58,7 +59,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
const trimmedStackName = data.stackName.trim();
|
||||
if (!STACK_NAME_REGEX.test(trimmedStackName)) {
|
||||
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
|
||||
return json({ error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check for name conflicts with existing stacks (regular/external/git)
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
getAllAutoUpdateGitStacks,
|
||||
getAllEnvUpdateCheckSettings,
|
||||
getAllImagePruneSettings,
|
||||
getBackupConfigs,
|
||||
getBackupDestination,
|
||||
getBackupDestinations,
|
||||
getLastExecutionForSchedule,
|
||||
getRecentExecutionsForSchedule,
|
||||
getEnvironment,
|
||||
@@ -26,7 +29,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export interface ScheduleInfo {
|
||||
id: number;
|
||||
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
|
||||
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
|
||||
name: string;
|
||||
entityName: string;
|
||||
description?: string;
|
||||
@@ -210,6 +213,121 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
);
|
||||
schedules.push(...imagePruneSchedules);
|
||||
|
||||
// Get backup schedules (audit #17 — GET must match the SSE stream listing)
|
||||
const allBackupConfigs = await getBackupConfigs();
|
||||
const backupSchedules = await Promise.all(
|
||||
allBackupConfigs
|
||||
.filter(c => c.schedule)
|
||||
.map(async (config) => {
|
||||
const [env, dest, lastExecution, recentExecutions] = await Promise.all([
|
||||
config.environmentId ? getEnvironment(config.environmentId) : null,
|
||||
getBackupDestination(config.destinationId),
|
||||
getLastExecutionForSchedule('backup', config.id),
|
||||
getRecentExecutionsForSchedule('backup', config.id, 5)
|
||||
]);
|
||||
const timezone = config.environmentId ? await getEnvironmentTimezone(config.environmentId) : 'UTC';
|
||||
const isEnabled = config.enabled ?? false;
|
||||
const nextRun = isEnabled && config.schedule ? getNextRun(config.schedule, timezone) : null;
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
type: 'backup' as const,
|
||||
name: `Backup: ${config.targetName}`,
|
||||
entityName: config.targetName,
|
||||
description: `Back up ${config.type} to ${dest?.name || 'unknown destination'}`,
|
||||
environmentId: config.environmentId,
|
||||
environmentName: env?.name ?? null,
|
||||
enabled: isEnabled,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: config.schedule ?? null,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExecution ?? null,
|
||||
recentExecutions,
|
||||
isSystem: false
|
||||
};
|
||||
})
|
||||
);
|
||||
schedules.push(...backupSchedules);
|
||||
|
||||
// Get repo maintenance schedules (prune + check + verify from destination
|
||||
// policies). Synthetic ids: dest.id + 100000/200000/300000 (matches the
|
||||
// stream endpoint and the run/toggle/delete REPO_ID_OFFSET decoding).
|
||||
const allDestinations = await getBackupDestinations();
|
||||
for (const dest of allDestinations) {
|
||||
const policies = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
|
||||
if (policies.pruneEnabled && policies.pruneSchedule) {
|
||||
const [lastExec, recentExecs] = await Promise.all([
|
||||
getLastExecutionForSchedule('repo_prune', dest.id),
|
||||
getRecentExecutionsForSchedule('repo_prune', dest.id, 5)
|
||||
]);
|
||||
const nextRun = getNextRun(policies.pruneSchedule);
|
||||
const maxUnused = policies.pruneMaxUnused ?? '10';
|
||||
schedules.push({
|
||||
id: dest.id + 100000,
|
||||
type: 'repo_prune' as const,
|
||||
name: `Prune: ${dest.name}`,
|
||||
entityName: dest.name,
|
||||
description: `Prune unused data from ${dest.name} (max unused ${maxUnused}%)`,
|
||||
environmentId: null,
|
||||
environmentName: null,
|
||||
enabled: true,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: policies.pruneSchedule,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExec ?? null,
|
||||
recentExecutions: recentExecs,
|
||||
isSystem: false
|
||||
});
|
||||
}
|
||||
if (policies.checkEnabled && policies.checkSchedule) {
|
||||
const [lastExec, recentExecs] = await Promise.all([
|
||||
getLastExecutionForSchedule('repo_check', dest.id),
|
||||
getRecentExecutionsForSchedule('repo_check', dest.id, 5)
|
||||
]);
|
||||
const nextRun = getNextRun(policies.checkSchedule);
|
||||
schedules.push({
|
||||
id: dest.id + 200000,
|
||||
type: 'repo_check' as const,
|
||||
name: `Check: ${dest.name}`,
|
||||
entityName: dest.name,
|
||||
description: `Check integrity of ${dest.name}`,
|
||||
environmentId: null,
|
||||
environmentName: null,
|
||||
enabled: true,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: policies.checkSchedule,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExec ?? null,
|
||||
recentExecutions: recentExecs,
|
||||
isSystem: false
|
||||
});
|
||||
}
|
||||
if (policies.verifyEnabled && policies.verifySchedule) {
|
||||
const [lastExec, recentExecs] = await Promise.all([
|
||||
getLastExecutionForSchedule('repo_verify', dest.id),
|
||||
getRecentExecutionsForSchedule('repo_verify', dest.id, 5)
|
||||
]);
|
||||
const nextRun = getNextRun(policies.verifySchedule);
|
||||
const subset = policies.verifyDataSubset || '5%';
|
||||
schedules.push({
|
||||
id: dest.id + 300000,
|
||||
type: 'repo_verify' as const,
|
||||
name: `Verify: ${dest.name}`,
|
||||
entityName: dest.name,
|
||||
description: `Verify ${subset} of data in ${dest.name}`,
|
||||
environmentId: null,
|
||||
environmentName: null,
|
||||
enabled: true,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: policies.verifySchedule,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExec ?? null,
|
||||
recentExecutions: recentExecs,
|
||||
isSystem: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get system schedules
|
||||
const systemSchedules = await getSystemSchedules();
|
||||
const sysSchedules = await Promise.all(
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
getGitStack,
|
||||
updateGitStack,
|
||||
deleteEnvUpdateCheckSettings,
|
||||
deleteImagePruneSettings
|
||||
deleteImagePruneSettings,
|
||||
deleteBackupConfig,
|
||||
getBackupConfig,
|
||||
getBackupDestination,
|
||||
updateBackupDestination
|
||||
} from '$lib/server/db';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
@@ -75,6 +79,39 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
unregisterSchedule(scheduleId, 'image_prune');
|
||||
return json({ success: true });
|
||||
|
||||
} else if (type === 'backup') {
|
||||
const cfg = await getBackupConfig(scheduleId);
|
||||
if (!cfg) {
|
||||
return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
}
|
||||
const envDenied = await auth.requireEnvAccess(cfg.environmentId);
|
||||
if (envDenied) return envDenied;
|
||||
await deleteBackupConfig(scheduleId);
|
||||
unregisterSchedule(scheduleId, 'backup');
|
||||
return json({ success: true });
|
||||
|
||||
} else if (type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify') {
|
||||
// Repo maintenance schedules are policy-driven; there is no standalone
|
||||
// row to delete. "Delete" here means disable the policy (audit #18).
|
||||
// The schedule id is synthetic — decode to the real destination id.
|
||||
const REPO_ID_OFFSET: Record<string, number> = {
|
||||
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
|
||||
};
|
||||
const destId = scheduleId - REPO_ID_OFFSET[type];
|
||||
const dest = await getBackupDestination(destId);
|
||||
if (!dest) {
|
||||
return json({ error: 'Destination not found' }, { status: 404 });
|
||||
}
|
||||
const policies = dest.policies
|
||||
? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })()
|
||||
: {};
|
||||
const enabledKey = type === 'repo_prune' ? 'pruneEnabled'
|
||||
: type === 'repo_check' ? 'checkEnabled' : 'verifyEnabled';
|
||||
policies[enabledKey] = false;
|
||||
await updateBackupDestination(destId, { policies: JSON.stringify(policies) });
|
||||
unregisterSchedule(destId, type);
|
||||
return json({ success: true });
|
||||
|
||||
} else if (type === 'system_cleanup') {
|
||||
return json({ error: 'System schedules cannot be removed' }, { status: 400 });
|
||||
|
||||
|
||||
@@ -11,8 +11,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
|
||||
import { getBackupConfig, getBackupDestination } from '$lib/server/db';
|
||||
import { runScheduledBackup } from '$lib/server/scheduler/tasks/backup';
|
||||
import { runRepoPrune, runRepoCheck, runRepoVerify } from '$lib/server/scheduler/tasks/repo-maintenance';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db';
|
||||
import { BACKUPS_ENABLED } from '$lib/server/features';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -28,6 +32,20 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
return json({ error: 'Invalid schedule ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// BETA GATE: backup-type schedules are unreachable unless FEAT_BACKUPS_ENABLED (see features.ts).
|
||||
if (!BACKUPS_ENABLED && (type === 'backup' || type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify')) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// The schedules stream emits synthetic IDs for the three repo-maintenance
|
||||
// rows per destination (dest.id + 100000/200000/300000) to keep them unique
|
||||
// in the UI list. Decode back to the real destination id here (audit #7)
|
||||
// so getBackupDestination() actually matches. Backup/other types pass through.
|
||||
const REPO_ID_OFFSET: Record<string, number> = {
|
||||
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
|
||||
};
|
||||
const destId = REPO_ID_OFFSET[type] ? scheduleId - REPO_ID_OFFSET[type] : scheduleId;
|
||||
|
||||
// Resolve schedule → environmentId so we can enforce per-env access
|
||||
// before triggering. System schedules (env null) are gated only by
|
||||
// the global schedules:run check above.
|
||||
@@ -52,6 +70,24 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
case 'system_cleanup':
|
||||
scheduleEnvId = null;
|
||||
break;
|
||||
case 'backup': {
|
||||
// Backup schedules ARE env-scoped (audit #6: these were missing here
|
||||
// and 400'd before ever reaching the dispatch switch below).
|
||||
const config = await getBackupConfig(scheduleId);
|
||||
if (!config) return json({ error: 'Backup config not found' }, { status: 404 });
|
||||
scheduleEnvId = config.environmentId;
|
||||
break;
|
||||
}
|
||||
case 'repo_prune':
|
||||
case 'repo_check':
|
||||
case 'repo_verify': {
|
||||
// Repo maintenance is destination-scoped, not env-scoped. Validate the
|
||||
// (decoded) destination exists; access is gated by schedules:run only.
|
||||
const dest = await getBackupDestination(destId);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
scheduleEnvId = null;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return json({ error: 'Invalid schedule type' }, { status: 400 });
|
||||
}
|
||||
@@ -77,6 +113,37 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
case 'image_prune':
|
||||
result = await triggerImagePrune(scheduleId);
|
||||
break;
|
||||
case 'backup': {
|
||||
const config = await getBackupConfig(scheduleId);
|
||||
if (!config) {
|
||||
return json({ error: 'Backup config not found' }, { status: 404 });
|
||||
}
|
||||
await runScheduledBackup(scheduleId, config.targetName, config.environmentId, 'manual');
|
||||
result = { success: true };
|
||||
break;
|
||||
}
|
||||
case 'repo_prune': {
|
||||
const dest = await getBackupDestination(destId);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
await runRepoPrune(destId, dest.name, 'manual');
|
||||
result = { success: true };
|
||||
break;
|
||||
}
|
||||
case 'repo_check': {
|
||||
const dest = await getBackupDestination(destId);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
await runRepoCheck(destId, dest.name, 'manual');
|
||||
result = { success: true };
|
||||
break;
|
||||
}
|
||||
case 'repo_verify': {
|
||||
const dest = await getBackupDestination(destId);
|
||||
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
|
||||
const pol = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
|
||||
await runRepoVerify(destId, dest.name, pol.verifyDataSubset || '5%', 'manual');
|
||||
result = { success: true };
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unreachable — validated in the resolution switch above.
|
||||
return json({ error: 'Invalid schedule type' }, { status: 400 });
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
|
||||
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings, getBackupConfig, updateBackupConfig, getBackupDestination, updateBackupDestination } from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackup, auditBackupDestination } from '$lib/server/audit';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'edit');
|
||||
@@ -110,6 +112,56 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
unregisterSchedule(scheduleId, 'image_prune');
|
||||
}
|
||||
|
||||
return json({ success: true, enabled: newEnabled });
|
||||
} else if (type === 'backup') {
|
||||
const config = await getBackupConfig(scheduleId);
|
||||
if (!config) {
|
||||
return json({ error: 'Backup config not found' }, { status: 404 });
|
||||
}
|
||||
const envDenied = await auth.requireEnvAccess(config.environmentId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const newEnabled = !config.enabled;
|
||||
await updateBackupConfig(scheduleId, { enabled: newEnabled });
|
||||
|
||||
if (newEnabled && config.schedule) {
|
||||
await registerSchedule(scheduleId, 'backup', config.environmentId);
|
||||
} else {
|
||||
unregisterSchedule(scheduleId, 'backup');
|
||||
}
|
||||
|
||||
await auditBackup(event, 'update', config.targetName, config.environmentId, { configId: scheduleId, enabled: newEnabled });
|
||||
|
||||
return json({ success: true, enabled: newEnabled });
|
||||
} else if (type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify') {
|
||||
// Repo maintenance schedules are policy-driven on the destination.
|
||||
// Toggling flips the corresponding *Enabled flag in the destination's
|
||||
// policies JSON (audit #18). The schedule id is synthetic — decode it
|
||||
// back to the real destination id (same offsets as the run endpoint).
|
||||
const REPO_ID_OFFSET: Record<string, number> = {
|
||||
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
|
||||
};
|
||||
const destId = scheduleId - REPO_ID_OFFSET[type];
|
||||
const dest = await getBackupDestination(destId);
|
||||
if (!dest) {
|
||||
return json({ error: 'Destination not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const policies = dest.policies
|
||||
? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })()
|
||||
: {};
|
||||
const enabledKey = type === 'repo_prune' ? 'pruneEnabled'
|
||||
: type === 'repo_check' ? 'checkEnabled' : 'verifyEnabled';
|
||||
const newEnabled = !policies[enabledKey];
|
||||
policies[enabledKey] = newEnabled;
|
||||
|
||||
await updateBackupDestination(destId, { policies: JSON.stringify(policies) });
|
||||
|
||||
// Re-register (or unregister) the repo maintenance job (env-less).
|
||||
await registerSchedule(destId, type, null);
|
||||
|
||||
await auditBackupDestination(event, 'update', destId, dest.name, { policy: enabledKey, enabled: newEnabled });
|
||||
|
||||
return json({ success: true, enabled: newEnabled });
|
||||
} else if (type === 'system_cleanup') {
|
||||
return json({ error: 'System schedules cannot be paused' }, { status: 400 });
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - scheduleType: 'container_update' | 'git_stack_sync'
|
||||
* - scheduleId: number
|
||||
* - environmentId: number
|
||||
* - status: 'queued' | 'running' | 'success' | 'failed' | 'skipped'
|
||||
* - status: 'queued' | 'running' | 'success' | 'warning' | 'failed' | 'skipped'
|
||||
* - triggeredBy: 'cron' | 'webhook' | 'manual'
|
||||
* - fromDate: ISO date string
|
||||
* - toDate: ISO date string
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
getAllAutoUpdateGitStacks,
|
||||
getAllEnvUpdateCheckSettings,
|
||||
getAllImagePruneSettings,
|
||||
getBackupConfigs,
|
||||
getBackupDestination,
|
||||
getBackupDestinations,
|
||||
getLastExecutionForSchedule,
|
||||
getRecentExecutionsForSchedule,
|
||||
getEnvironment,
|
||||
@@ -180,6 +183,121 @@ async function getSchedulesData(): Promise<ScheduleInfo[]> {
|
||||
);
|
||||
schedules.push(...imagePruneSchedules);
|
||||
|
||||
// Get backup schedules
|
||||
const allBackupConfigs = await getBackupConfigs();
|
||||
const backupSchedules = await Promise.all(
|
||||
allBackupConfigs
|
||||
.filter(c => c.schedule)
|
||||
.map(async (config) => {
|
||||
const [env, dest, lastExecution, recentExecutions] = await Promise.all([
|
||||
config.environmentId ? getEnvironment(config.environmentId) : null,
|
||||
getBackupDestination(config.destinationId),
|
||||
getLastExecutionForSchedule('backup', config.id),
|
||||
getRecentExecutionsForSchedule('backup', config.id, 5)
|
||||
]);
|
||||
const timezone = config.environmentId ? await getEnvironmentTimezone(config.environmentId) : 'UTC';
|
||||
const isEnabled = config.enabled ?? false;
|
||||
const nextRun = isEnabled && config.schedule ? getNextRun(config.schedule, timezone) : null;
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
type: 'backup' as const,
|
||||
name: `Backup: ${config.targetName}`,
|
||||
entityName: config.targetName,
|
||||
description: `Back up ${config.type} to ${dest?.name || 'unknown destination'}`,
|
||||
environmentId: config.environmentId,
|
||||
environmentName: env?.name ?? null,
|
||||
enabled: isEnabled,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: config.schedule ?? null,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExecution ?? null,
|
||||
recentExecutions,
|
||||
isSystem: false
|
||||
};
|
||||
})
|
||||
);
|
||||
schedules.push(...backupSchedules);
|
||||
|
||||
// Get repo maintenance schedules (prune + check from destination policies)
|
||||
const allDestinations = await getBackupDestinations();
|
||||
for (const dest of allDestinations) {
|
||||
const policies = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
|
||||
if (policies.pruneEnabled && policies.pruneSchedule) {
|
||||
const [lastExec, recentExecs] = await Promise.all([
|
||||
getLastExecutionForSchedule('repo_prune', dest.id),
|
||||
getRecentExecutionsForSchedule('repo_prune', dest.id, 5)
|
||||
]);
|
||||
const nextRun = getNextRun(policies.pruneSchedule, 'UTC');
|
||||
const maxUnused = policies.pruneMaxUnused ?? '10';
|
||||
schedules.push({
|
||||
id: dest.id + 100000,
|
||||
type: 'repo_prune' as any,
|
||||
name: `Prune: ${dest.name}`,
|
||||
entityName: dest.name,
|
||||
description: `Prune unused data from ${dest.name} (max unused ${maxUnused}%)`,
|
||||
maxUnused: String(maxUnused),
|
||||
environmentId: null,
|
||||
environmentName: null,
|
||||
enabled: true,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: policies.pruneSchedule,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExec ?? null,
|
||||
recentExecutions: recentExecs,
|
||||
isSystem: false
|
||||
});
|
||||
}
|
||||
if (policies.checkEnabled && policies.checkSchedule) {
|
||||
const [lastExec, recentExecs] = await Promise.all([
|
||||
getLastExecutionForSchedule('repo_check', dest.id),
|
||||
getRecentExecutionsForSchedule('repo_check', dest.id, 5)
|
||||
]);
|
||||
const nextRun = getNextRun(policies.checkSchedule, 'UTC');
|
||||
schedules.push({
|
||||
id: dest.id + 200000,
|
||||
type: 'repo_check' as any,
|
||||
name: `Check: ${dest.name}`,
|
||||
entityName: dest.name,
|
||||
description: `Check integrity of ${dest.name}`,
|
||||
environmentId: null,
|
||||
environmentName: null,
|
||||
enabled: true,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: policies.checkSchedule,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExec ?? null,
|
||||
recentExecutions: recentExecs,
|
||||
isSystem: false
|
||||
});
|
||||
}
|
||||
if (policies.verifyEnabled && policies.verifySchedule) {
|
||||
const [lastExec, recentExecs] = await Promise.all([
|
||||
getLastExecutionForSchedule('repo_verify', dest.id),
|
||||
getRecentExecutionsForSchedule('repo_verify', dest.id, 5)
|
||||
]);
|
||||
const nextRun = getNextRun(policies.verifySchedule, 'UTC');
|
||||
const subset = policies.verifyDataSubset || '5%';
|
||||
schedules.push({
|
||||
id: dest.id + 300000,
|
||||
type: 'repo_verify' as any,
|
||||
name: `Verify: ${dest.name}`,
|
||||
entityName: dest.name,
|
||||
description: `Verify ${subset} of data in ${dest.name}`,
|
||||
dataSubset: subset,
|
||||
environmentId: null,
|
||||
environmentName: null,
|
||||
enabled: true,
|
||||
scheduleType: 'custom',
|
||||
cronExpression: policies.verifySchedule,
|
||||
nextRun: nextRun?.toISOString() ?? null,
|
||||
lastExecution: lastExec ?? null,
|
||||
recentExecutions: recentExecs,
|
||||
isSystem: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get system schedules
|
||||
const systemSchedules = await getSystemSchedules();
|
||||
const sysSchedules = await Promise.all(
|
||||
|
||||
@@ -35,6 +35,12 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
|
||||
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
|
||||
import { DEFAULT_HELPER_IMAGE } from '$lib/server/backups/restic';
|
||||
|
||||
// The real engine default (version-pinned, `-baseline`-aware). NOT a hardcoded
|
||||
// `:latest` — that would advertise a floating tag the backup engine never uses and,
|
||||
// if the user saved it, pin them to a helper that never re-pulls (see restic.ts).
|
||||
const DEFAULT_BACKUP_IMAGE = DEFAULT_HELPER_IMAGE;
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
@@ -95,6 +101,8 @@ export interface GeneralSettings {
|
||||
defaultComposeTemplate: string;
|
||||
// Label filter mode
|
||||
labelFilterMode: 'any' | 'all';
|
||||
// Backup image
|
||||
defaultBackupImage: string;
|
||||
// Whether to surface URLs inferred from reverse-proxy labels — currently
|
||||
// Traefik (traefik.http.routers.*) and Pangolin
|
||||
// (pangolin.{public,private}-resources.*).
|
||||
@@ -170,7 +178,8 @@ services:
|
||||
# networks:
|
||||
# default:
|
||||
# driver: bridge
|
||||
`
|
||||
`,
|
||||
defaultBackupImage: DEFAULT_BACKUP_IMAGE
|
||||
};
|
||||
|
||||
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
|
||||
@@ -251,6 +260,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultTrivyImage,
|
||||
defaultComposeTemplate,
|
||||
labelFilterMode,
|
||||
defaultBackupImage,
|
||||
honorProxyLabels,
|
||||
showImageChangelogLinks,
|
||||
showWhatsNew,
|
||||
@@ -299,6 +309,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('default_backup_image'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('show_whats_new'),
|
||||
@@ -351,6 +362,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
defaultBackupImage: defaultBackupImage ?? DEFAULT_BACKUP_IMAGE,
|
||||
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
showWhatsNew: showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
|
||||
@@ -375,7 +387,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -521,6 +533,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
|
||||
await setSetting('label_filter_mode', labelFilterMode);
|
||||
}
|
||||
if (defaultBackupImage !== undefined && typeof defaultBackupImage === 'string') {
|
||||
await setSetting('default_backup_image', defaultBackupImage);
|
||||
}
|
||||
if (honorProxyLabels !== undefined && typeof honorProxyLabels === 'boolean') {
|
||||
await setSetting('honor_proxy_labels', honorProxyLabels);
|
||||
}
|
||||
@@ -593,6 +608,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultTrivyImageVal,
|
||||
defaultComposeTemplateVal,
|
||||
labelFilterModeVal,
|
||||
defaultBackupImageVal,
|
||||
honorProxyLabelsVal,
|
||||
showImageChangelogLinksVal,
|
||||
showWhatsNewVal,
|
||||
@@ -641,6 +657,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('default_backup_image'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('show_whats_new'),
|
||||
@@ -693,6 +710,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
defaultBackupImage: defaultBackupImageVal ?? DEFAULT_BACKUP_IMAGE,
|
||||
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { pull, build, forceRecreate } = body as {
|
||||
pull?: boolean;
|
||||
build?: boolean;
|
||||
|
||||
@@ -6,7 +6,9 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* Scan every (tagged) image in the environment for vulnerabilities.
|
||||
* Scan every image in the environment for vulnerabilities. Tagged images scan by
|
||||
* tag; untagged but digest-pinned images (e.g. renovate `@sha256:` pins) scan by
|
||||
* their repo digest so they aren't silently skipped (#1286).
|
||||
* Reuses the per-image scan flow, sequentially, reporting N/total progress.
|
||||
* A single image failing does not abort the batch.
|
||||
*/
|
||||
@@ -26,9 +28,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
return createJobResponse(async (send, isCancelled) => {
|
||||
const images = await listImages(envId);
|
||||
|
||||
// One scan target per image: use the first usable tag, skip dangling/<none>.
|
||||
// One scan target per image: prefer a usable tag; for untagged images
|
||||
// (digest-pinned via renovate etc.) fall back to a repo digest so they
|
||||
// aren't silently skipped (#1286). Truly dangling images — no tag AND no
|
||||
// digest — are left out; they're build cruft, not real scan targets.
|
||||
const targets = images
|
||||
.map((img) => (img.tags || []).find((t) => t && !t.includes('<none>')))
|
||||
.map((img) => {
|
||||
const tag = (img.tags || []).find((t) => t && !t.includes('<none>'));
|
||||
if (tag) return tag;
|
||||
return (img.repoDigests || []).find((d) => d && !d.includes('<none>')) || null;
|
||||
})
|
||||
.filter((t): t is string => !!t);
|
||||
// Dedupe (a repo can appear once per tag; scan each unique tag once)
|
||||
const uniqueTargets = Array.from(new Set(targets));
|
||||
|
||||
Reference in new issue
Block a user