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

@@ -0,0 +1,13 @@
CREATE TABLE "container_icon_overrides" (
"id" serial PRIMARY KEY NOT NULL,
"container_name" text NOT NULL,
"environment_id" integer,
"icon" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "container_icon_overrides_container_name_environment_id_unique" UNIQUE("container_name","environment_id")
);
--> statement-breakpoint
ALTER TABLE "backup_destinations" ADD COLUMN "cacert" text;--> statement-breakpoint
ALTER TABLE "backup_destinations" ADD COLUMN "tls_client_cert" text;--> statement-breakpoint
ALTER TABLE "container_icon_overrides" ADD CONSTRAINT "container_icon_overrides_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large. Load diff
+14
View File
@@ -92,6 +92,20 @@
"when": 1786967313223,
"tag": "0012_add_git_stack_branch",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1787416084323,
"tag": "0013_add_stack_icon",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1787468505425,
"tag": "0014_backup_tls_and_container_icons",
"breakpoints": true
}
]
}
@@ -0,0 +1,13 @@
CREATE TABLE `container_icon_overrides` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`container_name` text NOT NULL,
`environment_id` integer,
`icon` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `container_icon_overrides_container_name_environment_id_unique` ON `container_icon_overrides` (`container_name`,`environment_id`);--> statement-breakpoint
ALTER TABLE `backup_destinations` ADD `cacert` text;--> statement-breakpoint
ALTER TABLE `backup_destinations` ADD `tls_client_cert` text;
File diff suppressed because it is too large. Load diff
+14
View File
@@ -92,6 +92,20 @@
"when": 1786967313223,
"tag": "0012_add_git_stack_branch",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1787416077452,
"tag": "0013_add_stack_icon",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1787468495122,
"tag": "0014_backup_tls_and_container_icons",
"breakpoints": true
}
]
}
+27 -1
View File
@@ -47,6 +47,28 @@ export function gcsCredentialPreamble(): string {
return `if [ -n "\${GOOGLE_APPLICATION_CREDENTIALS_JSON:-}" ]; then umask 077; printf '%s' "$GOOGLE_APPLICATION_CREDENTIALS_JSON" > ${GCS_CRED_FILE}; export GOOGLE_APPLICATION_CREDENTIALS=${GCS_CRED_FILE}; fi; `;
}
/** Where the helper writes the destination's TLS PEMs before restic reads them. */
export const CACERT_FILE = '/tmp/dockhand-restic-ca.pem';
export const TLS_CLIENT_FILE = '/tmp/dockhand-restic-client.pem';
/**
* restic verifies a TLS backend (rest-server, self-hosted MinIO/S3, rclone-over-https)
* against a CA FILE at RESTIC_CACERT, and does client-cert mTLS from a PEM at
* RESTIC_TLS_CLIENT_CERT. The helper container can't bind a file from the Dockhand host
* (it runs on the TARGET daemon, possibly a remote/Hawser host), so we pass the PEM
* CONTENT as RESTIC_CACERT_PEM / RESTIC_TLS_CLIENT_CERT_PEM env vars; this preamble (same
* shell as restic) writes each to a 0600 file and points the real restic var at it.
*
* A no-op when both vars are empty/unset, so plaintext or public-CA backends are untouched.
* `printf %s` avoids the multi-line PEM being mangled by echo's escape handling.
*/
export function tlsCertPreamble(): string {
return (
`if [ -n "\${RESTIC_CACERT_PEM:-}" ]; then umask 077; printf '%s' "$RESTIC_CACERT_PEM" > ${CACERT_FILE}; export RESTIC_CACERT=${CACERT_FILE}; fi; ` +
`if [ -n "\${RESTIC_TLS_CLIENT_CERT_PEM:-}" ]; then umask 077; printf '%s' "$RESTIC_TLS_CLIENT_CERT_PEM" > ${TLS_CLIENT_FILE}; export RESTIC_TLS_CLIENT_CERT=${TLS_CLIENT_FILE}; fi; `
);
}
/**
* Fail loud if a bind-mounted local repo isn't visible on the target daemon's
* host: Docker would auto-create the missing path empty, silently backing up to
@@ -131,7 +153,8 @@ export function classifyProcError(
export function buildHelperEnv(
repository: string,
password: string,
allowedCloudVars: Record<string, string>
allowedCloudVars: Record<string, string>,
tls?: { cacert?: string | null; clientCert?: string | null }
): string[] {
const env = [
`RESTIC_REPOSITORY=${repository}`,
@@ -139,6 +162,9 @@ export function buildHelperEnv(
'RESTIC_PROGRESS_FPS=2',
];
for (const [k, v] of Object.entries(allowedCloudVars)) env.push(`${k}=${v}`);
// PEM CONTENT (not a path) - tlsCertPreamble writes it to a file in the helper.
if (tls?.cacert) env.push(`RESTIC_CACERT_PEM=${tls.cacert}`);
if (tls?.clientCert) env.push(`RESTIC_TLS_CLIENT_CERT_PEM=${tls.clientCert}`);
return env;
}
+48 -9
View File
@@ -31,7 +31,7 @@ import {
} from './security';
import { TIMEOUTS, type ResticRun, type TimeoutTier } from './models';
import { withTimeout, parseBackupFlags } from './helpers';
import { resticCommand, finishScript, readExitMarker, buildHelperEnv, buildHelperBinds, localRepoGuard, localRepoChown, classifyProcClose, classifyProcError, gcsCredentialPreamble } from './restic-script';
import { resticCommand, finishScript, readExitMarker, buildHelperEnv, buildHelperBinds, localRepoGuard, localRepoChown, classifyProcClose, classifyProcError, gcsCredentialPreamble, tlsCertPreamble } from './restic-script';
import { translateContainerPathViaMount } from '../host-path';
/** The destination's BACKUP/global flags for a given restic subcommand. Restore has its
@@ -63,6 +63,39 @@ export async function withGcsCredFile<T>(env: Record<string, string>, fn: (env:
}
}
/**
* For a LOCAL restic run (Dockhand host), a self-signed TLS backend needs a CA FILE at
* RESTIC_CACERT (and a client-cert PEM at RESTIC_TLS_CLIENT_CERT for mTLS). The certs are
* stored as PEM CONTENT on the destination; this writes them to private 0600 temp files,
* points the restic vars at them for the duration of `fn`, and deletes them after.
* A no-op when the destination has no certs (fn(env) unchanged). The helper-container path
* handles this itself in the shell script (tlsCertPreamble).
*/
export async function withTlsCertFiles<T>(
certs: { cacert: string | null; clientCert: string | null },
env: Record<string, string>,
fn: (env: Record<string, string>) => Promise<T>
): Promise<T> {
if (!certs.cacert && !certs.clientCert) return fn(env);
const dir = mkdtempSync(join(tmpdir(), 'dockhand-tls-'));
const runEnv = { ...env };
if (certs.cacert) {
const p = join(dir, 'ca.pem');
writeFileSync(p, certs.cacert, { mode: 0o600 });
runEnv.RESTIC_CACERT = p;
}
if (certs.clientCert) {
const p = join(dir, 'client.pem');
writeFileSync(p, certs.clientCert, { mode: 0o600 });
runEnv.RESTIC_TLS_CLIENT_CERT = p;
}
try {
return await fn(runEnv);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** The label key stamped on every helper container so a reaper only removes
* containers THIS installation created (never a co-located install's helper on a
* shared Docker daemon). */
@@ -135,7 +168,7 @@ export class Restic {
});
const fullArgs = [...args, ...backupFlagsForCommand(destination, args[0])];
return withGcsCredFile(env, (runEnv) => new Promise<ResticRun>((resolve) => {
return withTlsCertFiles({ cacert: decrypted.decryptedCacert, clientCert: decrypted.decryptedTlsClientCert }, env, (tlsEnv) => withGcsCredFile(tlsEnv, (runEnv) => new Promise<ResticRun>((resolve) => {
let stdout = '';
let stderr = '';
let settled = false;
@@ -153,7 +186,7 @@ export class Restic {
const { exitCode, stderr: stderrOut } = classifyProcError(err, stderr);
done({ exitCode, stdout, stderr: stderrOut });
});
}));
})));
}
/**
@@ -175,7 +208,7 @@ export class Restic {
});
const fullArgs = [...args, ...backupFlagsForCommand(destination, args[0])];
return withGcsCredFile(env, (runEnv) => new Promise((resolve) => {
return withTlsCertFiles({ cacert: decrypted.decryptedCacert, clientCert: decrypted.decryptedTlsClientCert }, env, (tlsEnv) => withGcsCredFile(tlsEnv, (runEnv) => new Promise((resolve) => {
const chunks: Buffer[] = [];
let stderr = '';
let settled = false;
@@ -194,7 +227,7 @@ export class Restic {
const { exitCode, stderr: stderrOut } = classifyProcError(err, stderr);
done({ exitCode, stdout: Buffer.concat(chunks), stderr: stderrOut });
});
}));
})));
}
/**
@@ -208,7 +241,8 @@ export class Restic {
const env = buildHelperEnv(
destination.repository,
decrypted.decryptedPassword,
filterCloudEnvVars(decrypted.decryptedEnvVars)
filterCloudEnvVars(decrypted.decryptedEnvVars),
{ cacert: decrypted.decryptedCacert, clientCert: decrypted.decryptedTlsClientCert }
);
const binds = buildHelperBinds(destination.repository, spec.binds, (repo) =>
destination.hostPath || translateLocalRepoPath(repo)
@@ -233,9 +267,14 @@ export class Restic {
// Prepended inside the marked command so it shares restic's shell; covers every
// helper op (backup/restore/swap) in one place.
const gcs = gcsCredentialPreamble();
// TLS: materialize a self-signed CA / client cert to a file + export RESTIC_CACERT /
// RESTIC_TLS_CLIENT_CERT before restic runs (no-op when the destination has none).
// Same shell as restic; covers every helper op. Works on a remote/Hawser daemon
// because the PEM rides an env var, not a host bind.
const tls = tlsCertPreamble();
const cmd = spec.script
? ['sh', '-c', finishScript(`( ${gcs}${guard}${spec.script} )`) + chown]
: ['sh', '-c', finishScript(`${gcs}${guard}${resticCommand([...argv, ...backupFlagsForCommand(destination, argv[0])])}`) + chown];
? ['sh', '-c', finishScript(`( ${gcs}${tls}${guard}${spec.script} )`) + chown]
: ['sh', '-c', finishScript(`${gcs}${tls}${guard}${resticCommand([...argv, ...backupFlagsForCommand(destination, argv[0])])}`) + chown];
// The small metadata files (metadata.json + the light stack-dir listing) go into
// the container via put-archive (docker cp), NOT the Cmd, so they can't blow
@@ -309,7 +348,7 @@ const HELPER_IMAGE_MEMO_MS = 30_000;
export async function ensureHelperImage(envId?: number | null): Promise<string> {
const image = (await getSetting('default_backup_image')) || DEFAULT_HELPER_IMAGE;
const memoKey = `${image}${envId ?? ''}`;
const memoKey = `${image} ${envId ?? ''}`;
const until = helperImagePresentUntil.get(memoKey);
if (until && until > Date.now()) return image; // recently confirmed present on this env
@@ -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');
@@ -25,6 +25,8 @@
repository: string;
hostPath?: string | null;
envVars?: Record<string, string>;
hasCacert?: boolean; // a CA cert is stored (the PEM itself is never sent back)
hasTlsClientCert?: boolean; // a client cert is stored
flags?: string;
backupFlags?: string; // split from the GET endpoint (legacy strings surface here)
restoreFlags?: string;
@@ -207,6 +209,29 @@
}
input.value = ''; // let the same file be re-picked
}
// TLS certs (PEM). Sent as content to the server, which stores them encrypted and
// NEVER sends the PEM back (only hasCacert/hasTlsClientCert booleans). So on edit
// these start blank = "keep the stored cert"; an explicit clear zeroes them out.
let formCacert = $state('');
let formTlsClientCert = $state('');
let hadCacert = $state(false); // a cert was already stored when the modal opened
let hadTlsClientCert = $state(false);
let caFileInput = $state<HTMLInputElement | null>(null);
let clientCertFileInput = $state<HTMLInputElement | null>(null);
async function uploadPem(which: 'ca' | 'client', e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
if (which === 'ca') formCacert = text; else formTlsClientCert = text;
} catch {
toast.error('Could not read the selected file');
}
input.value = '';
}
let formPassword = $state('');
let formBackupFlags = $state('');
let formRestoreFlags = $state('');
@@ -282,6 +307,7 @@
function resetForm() {
formName = ''; formBackendType = 'local'; formFields = {}; formPassword = '';
formBackupFlags = ''; formRestoreFlags = ''; formError = '';
formCacert = ''; formTlsClientCert = ''; hadCacert = false; hadTlsClientCert = false;
formSaving = false;
policyPruneEnabled = true; policyPruneSchedule = '0 0 1 * *'; policyPruneMaxUnused = '10';
policyCheckEnabled = true; policyCheckSchedule = '0 0 1 * *';
@@ -316,6 +342,11 @@
// (legacy bare strings surface as backupFlags), so the form binds them directly.
formBackupFlags = destination.backupFlags ?? '';
formRestoreFlags = destination.restoreFlags ?? '';
// PEMs are never returned; start blank and remember one is stored so the
// UI can say "leave blank to keep" and offer a Clear button.
formCacert = ''; formTlsClientCert = '';
hadCacert = !!destination.hasCacert;
hadTlsClientCert = !!destination.hasTlsClientCert;
formError = '';
// Load policies
const pol = destination.policies ? (() => { try { return JSON.parse(destination.policies); } catch { return {}; } })() : {};
@@ -380,7 +411,13 @@
// instance role) that hangs on a retry loop instead of failing fast.
const repository = selectedBackend.buildRepo(formFields);
const envVars = collectEnvVars();
payload = { repository, password: formPassword, envVars: Object.keys(envVars).length > 0 ? envVars : undefined };
payload = {
repository, password: formPassword,
envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
// Carry the TLS PEMs so a pre-save test of a private-CA backend can validate.
cacert: formCacert.trim() || undefined,
tlsClientCert: formTlsClientCert.trim() || undefined
};
}
const res = await fetch('/api/backup/destinations/test', {
method: 'POST',
@@ -486,6 +523,15 @@
policies: JSON.stringify(policies)
};
if (formPassword || !isEditing) body.password = formPassword || undefined;
// TLS certs, with keep/clear semantics on edit:
// typed value -> send it (set/replace)
// blank + one stored -> omit (server keeps the existing cert)
// explicitly cleared (blank + none was stored, or user hit Clear) -> send ''
// On create, blank just means "no cert" so an empty string is fine to omit.
if (formCacert.trim()) body.cacert = formCacert;
else if (isEditing && !hadCacert) body.cacert = '';
if (formTlsClientCert.trim()) body.tlsClientCert = formTlsClientCert;
else if (isEditing && !hadTlsClientCert) body.tlsClientCert = '';
const res = await fetch(isEditing ? `/api/backup/destinations/${destination!.id}` : '/api/backup/destinations', {
method: isEditing ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
@@ -681,6 +727,63 @@
</div>
</div>
<!-- TLS certificates: for any backend that speaks TLS with a private CA (self-hosted
rest-server, MinIO/S3, rclone-over-https). Optional; blank = use the system trust
store, no client cert. Shown for all backend types (someone may run MinIO with a
private CA). PEM content, uploaded or pasted. -->
<div class="border-t pt-3 mt-2 space-y-3">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">TLS certificates (optional)</span>
<p class="text-xs text-muted-foreground -mt-1">
For a backend served over HTTPS with a private/self-signed CA. Leave blank to use the system trust store.
</p>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label for="dest-cacert">CA certificate</Label>
<div class="flex gap-1">
{#if isEditing && hadCacert && !formCacert}
<Button variant="outline" size="sm" class="h-7 px-2 text-xs text-destructive" onclick={() => { hadCacert = false; }}>Clear</Button>
{/if}
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" onclick={() => caFileInput?.click()}>
<Upload class="mr-1 h-3 w-3" />Upload
</Button>
</div>
</div>
<input type="file" accept=".pem,.crt,.cer,.ca,application/x-pem-file,text/plain" class="hidden" bind:this={caFileInput} onchange={(e) => uploadPem('ca', e)} />
<Textarea
id="dest-cacert"
rows={4}
bind:value={formCacert}
placeholder={isEditing && hadCacert ? '(a CA certificate is stored - leave blank to keep it)' : '-----BEGIN CERTIFICATE-----'}
class="field-sizing-fixed max-h-40 resize-y overflow-auto font-mono text-xs"
/>
<p class="text-xs text-muted-foreground">Verifies the backend's TLS certificate (restic <code class="font-mono">RESTIC_CACERT</code>).</p>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label for="dest-client-cert">Client certificate (mTLS)</Label>
<div class="flex gap-1">
{#if isEditing && hadTlsClientCert && !formTlsClientCert}
<Button variant="outline" size="sm" class="h-7 px-2 text-xs text-destructive" onclick={() => { hadTlsClientCert = false; }}>Clear</Button>
{/if}
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" onclick={() => clientCertFileInput?.click()}>
<Upload class="mr-1 h-3 w-3" />Upload
</Button>
</div>
</div>
<input type="file" accept=".pem,.crt,.cer,.key,application/x-pem-file,text/plain" class="hidden" bind:this={clientCertFileInput} onchange={(e) => uploadPem('client', e)} />
<Textarea
id="dest-client-cert"
rows={4}
bind:value={formTlsClientCert}
placeholder={isEditing && hadTlsClientCert ? '(a client certificate is stored - leave blank to keep it)' : 'certificate + private key in one PEM'}
class="field-sizing-fixed max-h-40 resize-y overflow-auto font-mono text-xs"
/>
<p class="text-xs text-muted-foreground">Certificate + key in one PEM for mutual TLS (restic <code class="font-mono">RESTIC_TLS_CLIENT_CERT</code>).</p>
</div>
</div>
</div>
<!-- Policies section -->
<div class="border-t pt-3 mt-2 space-y-3">
+42
View File
@@ -17,6 +17,9 @@ import {
classifyProcError,
gcsCredentialPreamble,
GCS_CRED_FILE,
tlsCertPreamble,
CACERT_FILE,
TLS_CLIENT_FILE,
} from '../../src/lib/server/backups/restic-script';
describe('shellQuote', () => {
@@ -171,6 +174,21 @@ describe('buildHelperEnv', () => {
const env = buildHelperEnv('r', 'p', {});
expect(env).toHaveLength(3);
});
it('adds the TLS PEM content vars when certs are supplied', () => {
const env = buildHelperEnv('rest:https://x/repo', 'p', {}, { cacert: 'CA-PEM', clientCert: 'CLIENT-PEM' });
expect(env).toContain('RESTIC_CACERT_PEM=CA-PEM');
expect(env).toContain('RESTIC_TLS_CLIENT_CERT_PEM=CLIENT-PEM');
});
it('adds only the certs that are present (independent of each other)', () => {
const caOnly = buildHelperEnv('r', 'p', {}, { cacert: 'CA', clientCert: null });
expect(caOnly).toContain('RESTIC_CACERT_PEM=CA');
expect(caOnly.some((e) => e.startsWith('RESTIC_TLS_CLIENT_CERT_PEM='))).toBe(false);
});
it('adds no cert vars when tls is omitted (no-op for non-TLS backends)', () => {
const env = buildHelperEnv('s3:bucket', 'p', { AWS_ACCESS_KEY_ID: 'k' });
expect(env.some((e) => e.startsWith('RESTIC_CACERT_PEM='))).toBe(false);
expect(env.some((e) => e.startsWith('RESTIC_TLS_CLIENT_CERT_PEM='))).toBe(false);
});
});
describe('buildHelperBinds', () => {
@@ -289,3 +307,27 @@ describe('gcsCredentialPreamble', () => {
expect(p.endsWith('; ')).toBe(true);
});
});
describe('tlsCertPreamble (self-signed TLS backend: CA + client cert)', () => {
const p = tlsCertPreamble();
it('guards each cert on its own env var (no-op for plaintext/public-CA backends)', () => {
// Both branches are inside `if [ -n "${...:-}" ]`, so a run with neither var set
// does nothing - S3/local/public-HTTPS repos are untouched.
expect(p).toContain('if [ -n "${RESTIC_CACERT_PEM:-}" ]');
expect(p).toContain('if [ -n "${RESTIC_TLS_CLIENT_CERT_PEM:-}" ]');
});
it('writes the CA PEM to the private file and points RESTIC_CACERT at it', () => {
expect(p).toContain(`printf '%s' "$RESTIC_CACERT_PEM" > ${CACERT_FILE}`);
expect(p).toContain(`export RESTIC_CACERT=${CACERT_FILE}`);
});
it('writes the client PEM to the private file and points RESTIC_TLS_CLIENT_CERT at it', () => {
expect(p).toContain(`printf '%s' "$RESTIC_TLS_CLIENT_CERT_PEM" > ${TLS_CLIENT_FILE}`);
expect(p).toContain(`export RESTIC_TLS_CLIENT_CERT=${TLS_CLIENT_FILE}`);
});
it('sets a restrictive umask so neither cert file is world-readable', () => {
expect(p.match(/umask 077/g)?.length).toBe(2);
});
it('ends with a separator so it can be prepended before the next command', () => {
expect(p.endsWith('; ')).toBe(true);
});
});