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

+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