TLS backup repositories with a private CA and optional mutual TLS

This commit is contained in:
Jarek Krochmalski committed 2026-08-23 10:20:59 +02:00
1 parent f6ffadbacb
commit 7692758467
13 files changed
+7634 -17

No files matched your search

@@ -33,6 +33,11 @@ function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any {
} else {
delete result.envVars;
}
// TLS cert PEMs never reach the client; expose only whether each is set.
result.hasCacert = !!dest.cacert;
result.hasTlsClientCert = !!dest.tlsClientCert;
delete result.cacert;
delete result.tlsClientCert;
return result;
}
@@ -60,7 +65,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
* @openapi
* summary: Create a restic backup destination, auto-initialize and test the repository, and register its default maintenance schedules
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
* body: {name:string!, repository:string!, password:string!, envVars:{}, flags:string, hostPath:string, policies:string}
* body: {name:string!, repository:string!, password:string!, envVars:{}, flags:string, hostPath:string, cacert:string, tlsClientCert:string, policies:string}
* body-example: {"name":"S3 Offsite","repository":"s3:s3.amazonaws.com/my-bucket/restic","password":"***","envVars":{"AWS_ACCESS_KEY_ID":"***","AWS_SECRET_ACCESS_KEY":"***"}}
* resp-201: The created backup destination object (includes decrypted envVars since the caller just supplied them; password is stripped)
* resp-400: Invalid input — missing name/repository/password, unsupported/SSRF-blocked repository, invalid restic flags, or an invalid cron schedule in the policies
@@ -115,6 +120,8 @@ export const POST: RequestHandler = async (event) => {
envVars: body.envVars ? JSON.stringify(body.envVars) : null,
flags: flagsColumn,
hostPath: body.hostPath ?? null,
cacert: body.cacert || null,
tlsClientCert: body.tlsClientCert || null,
policies: body.policies ?? defaultPolicies
});
@@ -29,6 +29,13 @@ function prepareDestination(dest: any, includeSecrets: boolean): any {
} else {
delete result.envVars;
}
// TLS certs (PEM) are never sent to the client - not even the ciphertext, and not even
// to a manage caller (like the password). The edit form only needs to know one IS set so
// it can show "leave blank to keep", so expose booleans and strip the values.
result.hasCacert = !!dest.cacert;
result.hasTlsClientCert = !!dest.tlsClientCert;
delete result.cacert;
delete result.tlsClientCert;
// Split the stored `flags` JSON into separate fields the edit form binds to (legacy
// bare strings surface as backupFlags), so the UI never parses the string-vs-JSON column.
const { backup, restore } = parseBackupFlags(dest.flags);
@@ -72,7 +79,7 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
* summary: Update a backup destination, re-validating repository and flags when supplied and re-registering maintenance schedules when policies change
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
* body: {name:string, repository:string, password:string, envVars:{}, flags:string, hostPath:string, policies:string}
* body: {name:string, repository:string, password:string, envVars:{}, flags:string, hostPath:string, cacert:string, tlsClientCert:string, policies:string}
* body-example: {"name":"S3 Offsite (renamed)","policies":"{\"pruneEnabled\":true,\"pruneSchedule\":\"0 0 1 * *\"}"}
* resp-200: The updated backup destination object (password stripped, envVars echoed back to the managing caller)
* resp-400: Invalid input — invalid id, unsupported/SSRF-blocked repository, invalid restic flags, invalid policy cron, or switching to a local repository used by a remote-environment config
@@ -134,6 +141,8 @@ export const PUT: RequestHandler = async (event) => {
envVars: body.envVars !== undefined ? JSON.stringify(body.envVars) : undefined,
flags: flagsColumn,
hostPath: body.hostPath,
cacert: body.cacert,
tlsClientCert: body.tlsClientCert,
policies: body.policies
});
if (!updated) return json({ error: 'Update failed' }, { status: 500 });
@@ -5,7 +5,7 @@ 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 } from '$lib/server/backups/restic';
import { withGcsCredFile, withTlsCertFiles } from '$lib/server/backups/restic';
/**
* Test a backup destination configuration.
@@ -15,7 +15,7 @@ import { withGcsCredFile } from '$lib/server/backups/restic';
* @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:{}}
* 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}
@@ -32,6 +32,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
let repository: string;
let password: string;
let envVars: Record<string, string> = {};
// 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
@@ -41,6 +44,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
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) {
@@ -60,12 +64,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
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 withGcsCredFile(env, (runEnv) => new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
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 = '';
@@ -73,7 +82,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
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');