dorfteich/apps/backup/src/remote.ts
Claude Fable 5 394d1c811d
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s
#192: deploy-level backup target allowlist
BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains
where backups may go, enforced twice: the api rejects settings writes
and connection tests towards non-allowlisted hosts with admin-visible
error codes and resolves a non-allowlisted configured target to null,
and the sidecar enforces the same policy at the point of egress for the
WebDAV upload and the rsync mirror alike (shared policy helpers in
packages/shared/src/backup-target-policy.ts).

BREAKING: the empty default disables every remote target - backups stay
local only, the VS-NfD reference configuration (ADR 0026). Existing
deployments with a remote target must list its host or uploads and
mirror stop. The admin UI distinguishes unavailable-by-policy from
unconfigured (i18n de+en) and shows the permitted hosts.

Refs #192 (ADR 0026)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:17:14 +02:00

266 lines
9.7 KiB
TypeScript

import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import {
isBackupTargetAllowed,
remoteBundleId,
remoteBundleName,
type BackupEnv,
type BackupRemoteStatus,
} from '@dorfteich/shared';
import {
webdavCheck,
webdavDelete,
webdavGet,
webdavList,
webdavPut,
type WebDavTarget,
} from '@dorfteich/shared/webdav';
import { createArchiveOfFiles, extractArchiveTo } from './archive.js';
import { archiveFileName, backupIdTime, dumpFileName } from './backup-set.js';
import { sendUploadFailureMail } from './mail.js';
import { NEXTCLOUD_PASSWORD_SECRET_KEY, type BackupDbSettings } from './settings.js';
/**
* Off-host half of a backup run (issue #103): bundle a complete local set
* into ONE self-contained archive, upload it to the admin-configured
* Nextcloud folder via WebDAV, and prune expired remote bundles — never the
* newest one, mirroring the local guarantee (#83).
*/
export interface RemoteLogger {
info(details: object, message: string): void;
warn(details: object, message: string): void;
error(details: object, message: string): void;
}
/**
* The effective WebDAV target, or null when the feature is off or not fully
* configured. The app password comes from the wizard-written secret store
* (never the database); base URL and username from instance settings.
* Enforces the deploy-level target policy (issue #192, ADR 0026) at the
* point of egress: a configured host outside `BACKUP_ALLOWED_TARGETS`
* behaves like no target — `log` (when given) says why.
*/
export function resolveRemoteTarget(
settings: BackupDbSettings,
secrets: Record<string, string>,
allowlist: string[],
log?: RemoteLogger,
): WebDavTarget | null {
const { enabled, baseUrl, username, folder } = settings.nextcloud;
const password = secrets[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? '';
if (!enabled || !baseUrl || !username || !password) return null;
if (!isBackupTargetAllowed(allowlist, baseUrl)) {
log?.warn(
{ baseUrl, allowlist },
'remote backup target blocked: host not in BACKUP_ALLOWED_TARGETS (issue #192)',
);
return null;
}
return { baseUrl, username, password, folder };
}
/**
* Whether a scheduled run should upload: `off` never (manual trigger only),
* `daily` after every successful set, `weekly` when the last remote copy is
* at least ~a week old (half a day of slack so a slightly early nightly run
* does not skip its week).
*/
export function uploadDue(
schedule: BackupDbSettings['nextcloud']['uploadSchedule'],
lastSuccessfulUploadAt: string | null,
now: Date,
): boolean {
if (schedule === 'off') return false;
if (schedule === 'daily') return true;
if (!lastSuccessfulUploadAt) return true;
const ageMs = now.getTime() - new Date(lastSuccessfulUploadAt).getTime();
return ageMs >= 6.5 * 24 * 60 * 60 * 1000;
}
/** Contents of the bundle's manifest.json — the self-description a rebuild
* after total loss relies on (documented in the restore runbook). */
export interface BundleManifest {
schemaVersion: 1;
backupId: string;
createdAt: string;
files: string[];
}
/**
* Packs one complete local set into `dorfteich-backup-<id>.tar.gz` next to
* the set (staged as `.partial`, like every other artifact). Returns the
* bundle path; the caller uploads and then deletes it — bundles are derived
* data and would double the volume's footprint.
*/
export async function buildBundle(
backupsDir: string,
backupId: string,
now: Date,
): Promise<{ path: string; sizeBytes: number }> {
const files = [dumpFileName(backupId), archiveFileName(backupId)];
const manifest: BundleManifest = {
schemaVersion: 1,
backupId,
createdAt: now.toISOString(),
files,
};
const manifestName = 'manifest.json';
await writeFile(join(backupsDir, manifestName), JSON.stringify(manifest, null, 2) + '\n');
const bundlePath = join(backupsDir, remoteBundleName(backupId));
await createArchiveOfFiles(`${bundlePath}.partial`, backupsDir, [...files, manifestName]);
await rename(`${bundlePath}.partial`, bundlePath);
await rm(join(backupsDir, manifestName), { force: true });
return { path: bundlePath, sizeBytes: (await stat(bundlePath)).size };
}
/**
* Uploads the bundle for `backupId` and prunes expired remote bundles.
* Returns the new remote status; an upload failure alerts by mail (the
* local run stays succeeded — the admin card and readyz surface the gap).
*/
export async function uploadSet(deps: {
env: BackupEnv;
backupsDir: string;
target: WebDavTarget;
remoteRetentionDays: number;
backupId: string;
previous: BackupRemoteStatus | undefined;
now(): Date;
log: RemoteLogger;
}): Promise<BackupRemoteStatus> {
const { backupId, target } = deps;
const previousSuccess = deps.previous?.lastSuccessfulUpload ?? null;
let bundle: { path: string; sizeBytes: number } | null = null;
try {
const check = await webdavCheck(target);
if (!check.ok) throw new Error(check.error);
bundle = await buildBundle(deps.backupsDir, backupId, deps.now());
const put = await webdavPut(
target,
remoteBundleName(backupId),
Readable.toWeb(createReadStream(bundle.path)) as ReadableStream<Uint8Array>,
{ contentLength: bundle.sizeBytes },
);
if (!put.ok) throw new Error(put.error);
await pruneRemote(target, deps.remoteRetentionDays, deps.now(), deps.log);
const finishedAt = deps.now().toISOString();
deps.log.info({ backupId, sizeBytes: bundle.sizeBytes }, 'remote upload succeeded');
return {
lastUpload: { backupId, finishedAt, outcome: 'succeeded', sizeBytes: bundle.sizeBytes },
lastSuccessfulUpload: { backupId, finishedAt, sizeBytes: bundle.sizeBytes },
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.log.error({ backupId, error: message }, 'remote upload failed');
try {
const sent = await sendUploadFailureMail(deps.env, {
backupId,
error: message,
lastSuccessAt: previousSuccess?.finishedAt ?? null,
});
if (!sent) deps.log.warn({ backupId }, 'no BACKUP_MAIL_TO configured, upload alert not sent');
} catch (mailError) {
deps.log.error({ backupId, error: String(mailError) }, 'upload alert could not be sent');
}
return {
lastUpload: {
backupId,
finishedAt: deps.now().toISOString(),
outcome: 'failed',
error: message,
},
lastSuccessfulUpload: previousSuccess,
};
} finally {
if (bundle) await rm(bundle.path, { force: true });
await rm(join(deps.backupsDir, 'manifest.json'), { force: true });
}
}
/**
* Deletes remote bundles older than the retention window — never the newest
* one, even when expired (same guarantee as the local prune, #83). Foreign
* files in the folder are never touched. Prune failures are logged, not
* thrown: the upload itself succeeded and must count.
*/
export async function pruneRemote(
target: WebDavTarget,
retentionDays: number,
now: Date,
log: RemoteLogger,
): Promise<void> {
const listed = await webdavList(target);
if (!listed.ok) {
log.warn({ error: listed.error }, 'remote prune skipped: listing failed');
return;
}
const bundles = listed.value
.filter((entry) => !entry.isCollection)
.map((entry) => ({ name: entry.name, id: remoteBundleId(entry.name) }))
.filter((entry): entry is { name: string; id: string } => entry.id !== null)
.sort((a, b) => a.id.localeCompare(b.id));
const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
const newest = bundles.at(-1);
for (const bundle of bundles) {
if (bundle === newest) continue;
const time = backupIdTime(bundle.id);
if (!time || time.getTime() >= cutoff) continue;
const deleted = await webdavDelete(target, bundle.name);
if (deleted.ok) log.info({ name: bundle.name }, 'pruned expired remote bundle');
else log.warn({ name: bundle.name, error: deleted.error }, 'remote prune of bundle failed');
}
}
/**
* Downloads a remote bundle and unpacks its set artifacts into the backups
* directory (the in-app restore path, and documented for operators in the
* runbook). Verifies the manifest matches the requested id.
*/
export async function fetchRemoteSet(deps: {
backupsDir: string;
target: WebDavTarget;
backupId: string;
log: RemoteLogger;
}): Promise<void> {
const name = remoteBundleName(deps.backupId);
const response = await webdavGet(deps.target, name);
if (!response.ok) throw new Error(response.error);
if (!response.value.body) throw new Error(`download ${name}: empty response body`);
const downloadPath = join(deps.backupsDir, `${name}.download`);
const scratchDir = join(deps.backupsDir, `.restore-${deps.backupId}`);
try {
await pipeline(
Readable.fromWeb(response.value.body as import('node:stream/web').ReadableStream),
createWriteStream(downloadPath),
);
await mkdir(scratchDir, { recursive: true });
await extractArchiveTo(downloadPath, scratchDir);
const manifest = JSON.parse(
await readFile(join(scratchDir, 'manifest.json'), 'utf8'),
) as BundleManifest;
if (manifest.backupId !== deps.backupId) {
throw new Error(
`bundle manifest mismatch: requested ${deps.backupId}, bundle contains ${manifest.backupId}`,
);
}
for (const file of [dumpFileName(deps.backupId), archiveFileName(deps.backupId)]) {
await rename(join(scratchDir, file), join(deps.backupsDir, file));
}
deps.log.info({ backupId: deps.backupId }, 'remote set downloaded and unpacked');
} finally {
await rm(downloadPath, { force: true });
await rm(scratchDir, { recursive: true, force: true });
}
}