feat(backup): full DR bundle export + admin-configurable offsite destinations
Backend-agnostic disaster-recovery backup engine that runs on the current storage backend (no storage cutover required): - Full-bundle export: db.dump (pg_dump custom) + every storage blob + manifest.json with per-object SHA-256, streamed as a tar. Entry points: admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts. - Admin-configurable push destinations (backup_destinations table, migration 0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM at rest; API returns only *IsSet markers. - Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) + scripts/decrypt-backup.ts for restore. - Wired the previously-dead database-backup cron to runScheduledBackupPush (push to enabled destinations, prune to retention, alert super-admins on failure). Tests: 1608 unit/integration pass; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
104
src/lib/services/backup-destinations/s3.ts
Normal file
104
src/lib/services/backup-destinations/s3.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* S3-compatible backup transport — pushes the bundle to any S3 API endpoint
|
||||
* (AWS S3, Backblaze B2, Wasabi, Cloudflare R2, MinIO). Reuses the `minio`
|
||||
* client the storage backend already depends on, so no new SDK.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { Client as MinioClient } from 'minio';
|
||||
|
||||
import {
|
||||
BACKUP_NAME_PREFIX,
|
||||
sortBundlesNewestFirst,
|
||||
type BackupTransport,
|
||||
type S3DestConfig,
|
||||
} from './types';
|
||||
|
||||
/** Split a configured endpoint (host or URL) into minio's endPoint/port/useSSL. */
|
||||
export function parseS3Endpoint(
|
||||
endpoint: string,
|
||||
cfg: { useSSL?: boolean; port?: number },
|
||||
): { endPoint: string; port?: number; useSSL: boolean } {
|
||||
let host = endpoint.trim();
|
||||
let useSSL = cfg.useSSL ?? true;
|
||||
let port = cfg.port;
|
||||
const m = /^(https?):\/\/([^/:]+)(?::(\d+))?/i.exec(host);
|
||||
if (m) {
|
||||
useSSL = m[1]!.toLowerCase() === 'https';
|
||||
host = m[2]!;
|
||||
if (m[3]) port = Number(m[3]);
|
||||
} else {
|
||||
host = host.replace(/\/.*$/, '');
|
||||
}
|
||||
return { endPoint: host, ...(port ? { port } : {}), useSSL };
|
||||
}
|
||||
|
||||
export class S3Transport implements BackupTransport {
|
||||
private readonly prefix: string;
|
||||
|
||||
constructor(private readonly cfg: S3DestConfig) {
|
||||
// Normalise prefix to "" or "dir/".
|
||||
const p = (cfg.prefix ?? '').replace(/^\/+|\/+$/g, '');
|
||||
this.prefix = p ? `${p}/` : '';
|
||||
}
|
||||
|
||||
private client(): MinioClient {
|
||||
const { endPoint, port, useSSL } = parseS3Endpoint(this.cfg.endpoint, {
|
||||
useSSL: this.cfg.useSSL,
|
||||
port: this.cfg.port,
|
||||
});
|
||||
return new MinioClient({
|
||||
endPoint,
|
||||
...(port ? { port } : {}),
|
||||
useSSL,
|
||||
accessKey: this.cfg.accessKey,
|
||||
secretKey: this.cfg.secretKey,
|
||||
...(this.cfg.region ? { region: this.cfg.region } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async test(): Promise<void> {
|
||||
const exists = await this.client().bucketExists(this.cfg.bucket);
|
||||
if (!exists) throw new Error(`Bucket not found or not accessible: ${this.cfg.bucket}`);
|
||||
}
|
||||
|
||||
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
|
||||
const key = `${this.prefix}${remoteName}`;
|
||||
await this.client().fPutObject(this.cfg.bucket, key, localPath, {
|
||||
'Content-Type': 'application/x-tar',
|
||||
});
|
||||
const { stat } = await import('node:fs/promises');
|
||||
const s = await stat(localPath);
|
||||
return { remoteRef: `s3://${this.cfg.bucket}/${key}`, bytes: s.size };
|
||||
}
|
||||
|
||||
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
|
||||
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
|
||||
const client = this.client();
|
||||
const names = await this.listBundleKeys(client);
|
||||
const sorted = sortBundlesNewestFirst(names.map((k) => path.posix.basename(k)));
|
||||
const keepBasenames = new Set(sorted.slice(0, retentionCount));
|
||||
const toDelete = names.filter(
|
||||
(k) =>
|
||||
path.posix.basename(k).startsWith(BACKUP_NAME_PREFIX) &&
|
||||
!keepBasenames.has(path.posix.basename(k)),
|
||||
);
|
||||
for (const key of toDelete) await client.removeObject(this.cfg.bucket, key);
|
||||
return { deleted: toDelete.length };
|
||||
}
|
||||
|
||||
private listBundleKeys(client: MinioClient): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const keys: string[] = [];
|
||||
const stream = client.listObjectsV2(this.cfg.bucket, this.prefix, true);
|
||||
stream.on('data', (obj) => {
|
||||
if (obj.name && path.posix.basename(obj.name).startsWith(BACKUP_NAME_PREFIX)) {
|
||||
keys.push(obj.name);
|
||||
}
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(keys));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user