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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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} | ||||