TLS backup repositories with a private CA and optional mutual TLS
This commit is contained in:
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');
|
||||
|
||||
Reference in new issue
Block a user