import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; import { requireBackups } from '$lib/server/backups/route-guards'; import { getBackupDestination, 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'; import { withGcsCredFile, withTlsCertFiles } from '$lib/server/backups/restic'; /** * Test a backup destination configuration. * - With `destinationId`: uses saved credentials from DB * - Without: uses inline credentials from request body (for testing before save) * * @openapi * summary: Test a backup repository — either a saved destination (by destinationId) or inline credentials supplied for a not-yet-saved destination * description: Provide `destinationId` to test a saved destination using its stored credentials, or omit it and supply `repository`/`password`/`envVars` to test before saving. Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. destinationId from GET /api/backup/destinations. * body: {destinationId:integer, repository:string, password:string, envVars:{}, cacert:string, tlsClientCert:string} * body-example: {"repository":"s3:s3.amazonaws.com/my-bucket/restic","password":"***","envVars":{"AWS_ACCESS_KEY_ID":"***","AWS_SECRET_ACCESS_KEY":"***"}} * resp-200: Test result — { success: true } when the repository is reachable, or { success: false, needsInit?, error } otherwise * resp-200-example: {"success":true} * resp-400: Invalid input — missing repository/password for an inline test, or an unsupported/SSRF-blocked repository * resp-404: Destination not found (when testing a saved destinationId) */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'manage'); if (denied) return denied; const body = await request.json(); let repository: string; let password: string; let envVars: Record = {}; // TLS certs must reach restic here too, or testing a saved self-signed/private-CA // destination fails the handshake even though its backups succeed (they apply the cert). let certs: { cacert: string | null; clientCert: string | null } = { cacert: null, clientCert: null }; 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; certs = { cacert: decrypted.decryptedCacert, clientCert: decrypted.decryptedTlsClientCert }; } 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; } } // Pre-save test of a private-CA backend: take the PEMs straight from the body. certs = { cacert: typeof body.cacert === 'string' && body.cacert ? body.cacert : null, clientCert: typeof body.tlsClientCert === 'string' && body.tlsClientCert ? body.tlsClientCert : null }; } const env = buildResticEnv(process.env, { repository, password, envVars }); try { const result = await withTlsCertFiles(certs, env, (tlsEnv) => withGcsCredFile(tlsEnv, (runEnv) => new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { const proc = spawn('restic', ['snapshots', '--json', '--no-lock', '--latest', '1'], { env: runEnv, 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 }); } };