import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { promisify } from 'node:util'; import type { BackupEnv, BackupMirrorStatus } from '@dorfteich/shared'; import { sendMirrorFailureMail } from './mail.js'; import type { RemoteLogger } from './remote.js'; const execFileAsync = promisify(execFile); /** * The rsync mirror to a private host (issue #84, ADR 0015) — the operator * extra beside the admin-configured Nextcloud target (#103). After every * successful local run the set artifacts are rsynced to * `BACKUP_MIRROR_TARGET`; `--delete` keeps the remote retention aligned * with the local prune (the newest-set guarantee therefore carries over). * Only set files travel — status files and staging artifacts stay local. * rsync's delta transfer makes re-runs idempotent (0 files transferred). */ export interface MirrorConfig { target: string; sshKeyFile: string; sshPort: number; } /** The mirror configuration, or null when the env does not enable it. */ export function resolveMirrorConfig(env: BackupEnv): MirrorConfig | null { if (!env.BACKUP_MIRROR_TARGET || !env.BACKUP_MIRROR_SSH_KEY) return null; return { target: env.BACKUP_MIRROR_TARGET, sshKeyFile: env.BACKUP_MIRROR_SSH_KEY, sshPort: env.BACKUP_MIRROR_SSH_PORT, }; } /** * The rsync invocation: only complete-set artifacts (and the remote bundle * naming is local-only, so just dumps + archives), `--delete` inside that * filter for retention alignment. The known-hosts file lives on the * backups volume so the host key pins across container recreations; * `accept-new` covers the very first contact (inside the WireGuard tunnel). */ export function buildRsyncArgs(config: MirrorConfig, backupsDir: string): string[] { const ssh = [ 'ssh', `-i ${config.sshKeyFile}`, `-p ${config.sshPort}`, '-o StrictHostKeyChecking=accept-new', `-o UserKnownHostsFile=${join(backupsDir, '.mirror_known_hosts')}`, '-o BatchMode=yes', ].join(' '); return [ '--archive', // Fixed modes on the mirror host (dirs 750, files 640) — `--archive` // would otherwise copy the container-side modes onto the target. // Symbolic form: octal `--chmod` needs rsync ≥ 3, which not every // dev machine has (macOS ships 2.6.9); the symbolic one works on both. '--chmod=Du=rwx,Dg=rx,Do-rwx,Fu=rw,Fg=r,Fo-rwx', '--delete', '--stats', '--include=db-*.dump', '--include=files-*.tar.gz', '--exclude=*', '-e', ssh, `${backupsDir}/`, config.target, ]; } /** The transferred-file count from `rsync --stats` output. rsync 3 prints * "Number of regular files transferred", the ancient 2.6 (macOS) drops * "regular" — accept both. */ export function parseTransferredFiles(stats: string): number | undefined { const match = /Number of (?:regular )?files transferred:\s*([\d,.]+)/.exec(stats); if (!match) return undefined; return Number(match[1]!.replace(/[,.]/g, '')); } /** * Runs one mirror pass and returns the new mirror status. Failures are * reported in the status and alert by mail — never thrown: the local * backup succeeded and must count (issue #84 acceptance criteria). */ export async function mirrorSets(deps: { env: BackupEnv; config: MirrorConfig; backupsDir: string; previous: BackupMirrorStatus | undefined; now(): Date; log: RemoteLogger; }): Promise { const previousSuccessAt = deps.previous?.lastSuccessAt ?? null; try { if (!existsSync(deps.config.sshKeyFile)) { throw new Error(`mirror ssh key not found: ${deps.config.sshKeyFile}`); } const { stdout } = await execFileAsync('rsync', buildRsyncArgs(deps.config, deps.backupsDir), { maxBuffer: 16 * 1024 * 1024, }); const finishedAt = deps.now().toISOString(); const transferredFiles = parseTransferredFiles(stdout); deps.log.info({ target: deps.config.target, transferredFiles }, 'mirror run succeeded'); return { lastRun: { finishedAt, outcome: 'succeeded', transferredFiles }, lastSuccessAt: finishedAt, }; } catch (error) { const stderr = (error as { stderr?: string }).stderr?.trim(); const message = stderr || (error instanceof Error ? error.message : String(error)); deps.log.error({ target: deps.config.target, error: message }, 'mirror run failed'); try { const sent = await sendMirrorFailureMail(deps.env, { backupId: 'mirror', error: message, lastSuccessAt: previousSuccessAt, }); if (!sent) deps.log.warn({}, 'no BACKUP_MAIL_TO configured, mirror alert not sent'); } catch (mailError) { deps.log.error({ error: String(mailError) }, 'mirror alert could not be sent'); } return { lastRun: { finishedAt: deps.now().toISOString(), outcome: 'failed', error: message }, lastSuccessAt: previousSuccessAt, }; } }