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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -65,3 +65,6 @@ tmp/
|
||||
# Internal docs + Claude instructions: kept local-only, not in the shared repo
|
||||
docs/
|
||||
/CLAUDE.md
|
||||
|
||||
# Client-facing feature screenshots (real PII — do not commit)
|
||||
docs/feature-screenshots/
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"svgo": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
@@ -154,6 +155,7 @@
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2-sftp-client": "^9.0.6",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
|
||||
111
pnpm-lock.yaml
generated
111
pnpm-lock.yaml
generated
@@ -295,6 +295,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
ssh2-sftp-client:
|
||||
specifier: ^12.1.1
|
||||
version: 12.1.1
|
||||
svgo:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -374,6 +377,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/ssh2-sftp-client':
|
||||
specifier: ^9.0.6
|
||||
version: 9.0.6
|
||||
'@types/topojson-client':
|
||||
specifier: ^3.1.5
|
||||
version: 3.1.5
|
||||
@@ -3095,6 +3101,9 @@ packages:
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@20.19.41':
|
||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||
|
||||
@@ -3127,6 +3136,12 @@ packages:
|
||||
'@types/readdir-glob@1.1.5':
|
||||
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.6':
|
||||
resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==}
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||
|
||||
@@ -3582,6 +3597,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
asn1@0.2.6:
|
||||
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3694,6 +3712,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||
|
||||
better-auth@1.6.11:
|
||||
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
|
||||
peerDependencies:
|
||||
@@ -3856,6 +3877,10 @@ packages:
|
||||
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
|
||||
engines: {node: '>=0.2.0'}
|
||||
|
||||
buildcheck@0.0.7:
|
||||
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
bullmq@5.76.8:
|
||||
resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -3995,6 +4020,10 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
conf@15.1.0:
|
||||
resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4023,6 +4052,10 @@ packages:
|
||||
country-flag-icons@1.6.17:
|
||||
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
|
||||
|
||||
cpu-features@0.0.10:
|
||||
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -5723,6 +5756,9 @@ packages:
|
||||
msgpackr@2.0.1:
|
||||
resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==}
|
||||
|
||||
nan@2.27.0:
|
||||
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
|
||||
|
||||
nanoid@3.3.12:
|
||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -6629,6 +6665,14 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
ssh2-sftp-client@12.1.1:
|
||||
resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==}
|
||||
engines: {node: '>=18.20.4'}
|
||||
|
||||
ssh2@1.17.0:
|
||||
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -6966,6 +7010,9 @@ packages:
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
tweetnacl@0.14.5:
|
||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -6994,6 +7041,9 @@ packages:
|
||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typescript-eslint@8.59.3:
|
||||
resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -7021,6 +7071,9 @@ packages:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -9990,6 +10043,10 @@ snapshots:
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@20.19.41':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -10030,6 +10087,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.6':
|
||||
dependencies:
|
||||
'@types/ssh2': 1.15.5
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
@@ -10582,6 +10647,10 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
is-array-buffer: 3.0.5
|
||||
|
||||
asn1@0.2.6:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
@@ -10663,6 +10732,10 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.29: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
|
||||
better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
@@ -10803,6 +10876,9 @@ snapshots:
|
||||
|
||||
buffers@0.1.1: {}
|
||||
|
||||
buildcheck@0.0.7:
|
||||
optional: true
|
||||
|
||||
bullmq@5.76.8:
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
@@ -10936,6 +11012,13 @@ snapshots:
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
conf@15.1.0:
|
||||
dependencies:
|
||||
ajv: 8.20.0
|
||||
@@ -10971,6 +11054,12 @@ snapshots:
|
||||
|
||||
country-flag-icons@1.6.17: {}
|
||||
|
||||
cpu-features@0.0.10:
|
||||
dependencies:
|
||||
buildcheck: 0.0.7
|
||||
nan: 2.27.0
|
||||
optional: true
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@4.0.3:
|
||||
@@ -12736,6 +12825,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
msgpackr-extract: 3.0.3
|
||||
|
||||
nan@2.27.0:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanostores@1.3.0: {}
|
||||
@@ -13765,6 +13857,19 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
ssh2-sftp-client@12.1.1:
|
||||
dependencies:
|
||||
concat-stream: 2.0.0
|
||||
ssh2: 1.17.0
|
||||
|
||||
ssh2@1.17.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
bcrypt-pbkdf: 1.0.2
|
||||
optionalDependencies:
|
||||
cpu-features: 0.0.10
|
||||
nan: 2.27.0
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
@@ -14103,6 +14208,8 @@ snapshots:
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
tweetnacl@0.14.5: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -14146,6 +14253,8 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
reflect.getprototypeof: 1.0.10
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||
@@ -14172,6 +14281,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@7.25.0: {}
|
||||
|
||||
48
scripts/create-full-backup.ts
Normal file
48
scripts/create-full-backup.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json)
|
||||
* to a local file. Same code path as the admin "Download full backup" button
|
||||
* (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for
|
||||
* rehearsing the restore runbook (docs/backup-restore-runbook.md).
|
||||
*
|
||||
* pnpm tsx scripts/create-full-backup.ts [outfile.tar]
|
||||
*
|
||||
* Defaults the output name to ./pn-crm-backup-<timestamp>.tar in the CWD.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createFullBackupTar } from '@/lib/services/backup-export.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
|
||||
try {
|
||||
const dest = path.resolve(process.argv[2] ?? filename);
|
||||
await copyFile(tarPath, dest);
|
||||
logger.info(
|
||||
{
|
||||
dest,
|
||||
storageBackend: manifest.storageBackend,
|
||||
dbDumpBytes: manifest.database.sizeBytes,
|
||||
blobs: manifest.counts.blobs,
|
||||
blobBytes: manifest.counts.blobBytes,
|
||||
skipped: manifest.counts.skipped,
|
||||
},
|
||||
'Full backup written',
|
||||
);
|
||||
if (manifest.skipped.length) {
|
||||
logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage');
|
||||
}
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.stdout.write('', () => process.exit(0)))
|
||||
.catch((err) => {
|
||||
logger.error({ err }, 'Full backup failed');
|
||||
process.stderr.write('', () => process.exit(1));
|
||||
});
|
||||
31
scripts/decrypt-backup.ts
Normal file
31
scripts/decrypt-backup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Decrypt an encrypted backup bundle (`*.tar.enc`) produced when a destination
|
||||
* has bundle encryption enabled. Restore step — see
|
||||
* docs/backup-restore-runbook.md.
|
||||
*
|
||||
* BACKUP_PASSPHRASE='…' pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>
|
||||
*
|
||||
* The passphrase is read from $BACKUP_PASSPHRASE (not argv, to keep it out of
|
||||
* shell history / the process list).
|
||||
*/
|
||||
import { decryptFileToFile } from '@/lib/services/backup-destinations/bundle-encryption';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [input, output] = process.argv.slice(2);
|
||||
const passphrase = process.env.BACKUP_PASSPHRASE;
|
||||
if (!input || !output) {
|
||||
throw new Error(
|
||||
'Usage: BACKUP_PASSPHRASE=… pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>',
|
||||
);
|
||||
}
|
||||
if (!passphrase) throw new Error('Set BACKUP_PASSPHRASE in the environment');
|
||||
await decryptFileToFile(input, output, passphrase);
|
||||
process.stdout.write(`Decrypted → ${output}\n`, () => process.exit(0));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(
|
||||
`Decrypt failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
() => process.exit(1),
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
|
||||
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function BackupManagementPage() {
|
||||
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
|
||||
<PageHeader
|
||||
title="Backup & Restore"
|
||||
eyebrow="ADMIN"
|
||||
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
|
||||
description="Download a full backup, configure where automated backups are pushed, and browse history. Restore steps live in docs/backup-restore-runbook.md."
|
||||
/>
|
||||
<BackupAdminPanel />
|
||||
<BackupDestinationsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
62
src/app/api/v1/admin/backup/destinations/[id]/route.ts
Normal file
62
src/app/api/v1/admin/backup/destinations/[id]/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import {
|
||||
deleteDestination,
|
||||
updateDestination,
|
||||
type DestinationInput,
|
||||
} from '@/lib/services/backup-destinations.service';
|
||||
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/** Update a backup destination. Super-admin only. */
|
||||
export const PUT = withAuth(async (req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.update');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
const body = await parseBody(req, backupDestinationSchema);
|
||||
const updated = await updateDestination(id, body as DestinationInput);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'backup_destination',
|
||||
entityId: id,
|
||||
severity: 'warning',
|
||||
metadata: { name: updated.name, type: updated.type, enabled: updated.enabled },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** Delete a backup destination. Super-admin only. */
|
||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.delete');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
await deleteDestination(id);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'delete',
|
||||
entityType: 'backup_destination',
|
||||
entityId: id,
|
||||
severity: 'warning',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
38
src/app/api/v1/admin/backup/destinations/[id]/run/route.ts
Normal file
38
src/app/api/v1/admin/backup/destinations/[id]/run/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { pushBackupToDestination } from '@/lib/services/backup-destinations.service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// A full backup (pg_dump + every blob) is assembled before the push, so allow
|
||||
// a long run on large datasets.
|
||||
export const maxDuration = 3600;
|
||||
|
||||
/** Assemble a fresh full backup and push it to this destination now. Super-admin. */
|
||||
export const POST = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.run');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
const result = await pushBackupToDestination(id, {
|
||||
trigger: 'manual',
|
||||
triggeredBy: ctx.userId,
|
||||
});
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'backup_export',
|
||||
entityType: 'backup_destination',
|
||||
entityId: id,
|
||||
severity: 'warning',
|
||||
metadata: { bytes: result.bytes, remoteRef: result.remoteRef },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
24
src/app/api/v1/admin/backup/destinations/[id]/test/route.ts
Normal file
24
src/app/api/v1/admin/backup/destinations/[id]/test/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { testDestination } from '@/lib/services/backup-destinations.service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Test connectivity to a destination (connect + verify the target dir/bucket).
|
||||
* Returns `{ data: { ok: true } }` or a structured error the UI can surface.
|
||||
* Super-admin only.
|
||||
*/
|
||||
export const POST = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.test');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
await testDestination(id);
|
||||
return NextResponse.json({ data: { ok: true } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
47
src/app/api/v1/admin/backup/destinations/route.ts
Normal file
47
src/app/api/v1/admin/backup/destinations/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
createDestination,
|
||||
listDestinations,
|
||||
type DestinationInput,
|
||||
} from '@/lib/services/backup-destinations.service';
|
||||
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/** List configured backup destinations (secrets masked). Super-admin only. */
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.list');
|
||||
return NextResponse.json({ data: await listDestinations() });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** Create a backup destination. Super-admin only. */
|
||||
export const POST = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.create');
|
||||
const body = await parseBody(req, backupDestinationSchema);
|
||||
const created = await createDestination(body as DestinationInput);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'create',
|
||||
entityType: 'backup_destination',
|
||||
entityId: created.id,
|
||||
severity: 'warning',
|
||||
metadata: { name: created.name, type: created.type, enabled: created.enabled },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: created }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
71
src/app/api/v1/admin/backup/export/route.ts
Normal file
71
src/app/api/v1/admin/backup/export/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createFullBackupTar } from '@/lib/services/backup-export.service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// A full backup pg_dumps the DB and streams every blob; on a large dataset the
|
||||
// assembly phase can run for a while before the download starts. Lift the
|
||||
// platform timeout accordingly (no-op on hosts without a hard cap).
|
||||
export const maxDuration = 3600;
|
||||
|
||||
/**
|
||||
* Stream a full disaster-recovery bundle (db.dump + all blobs + manifest.json)
|
||||
* as a tar download. Super-admin only — this egresses every tenant's data.
|
||||
*
|
||||
* The bundle is assembled to a temp file first, so any failure (pg_dump,
|
||||
* storage read) surfaces as a clean JSON error *before* the download begins
|
||||
* rather than as a truncated tar. The temp file is removed once the response
|
||||
* stream closes (including on client disconnect).
|
||||
*/
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.export');
|
||||
|
||||
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
|
||||
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'backup_export',
|
||||
entityType: 'system_backup',
|
||||
entityId: filename,
|
||||
severity: 'warning',
|
||||
source: 'user',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
metadata: {
|
||||
storageBackend: manifest.storageBackend,
|
||||
blobs: manifest.counts.blobs,
|
||||
blobBytes: manifest.counts.blobBytes,
|
||||
skipped: manifest.counts.skipped,
|
||||
dbDumpBytes: manifest.database.sizeBytes,
|
||||
},
|
||||
});
|
||||
|
||||
const { size } = await stat(tarPath);
|
||||
const nodeStream = createReadStream(tarPath);
|
||||
// Remove the temp tar once it's been fully sent or the client bails.
|
||||
nodeStream.on('close', () => void cleanup());
|
||||
nodeStream.on('error', () => void cleanup());
|
||||
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
|
||||
return new NextResponse(webStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-tar',
|
||||
'Content-Length': String(size),
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
43
src/app/api/v1/admin/backup/schedule/route.ts
Normal file
43
src/app/api/v1/admin/backup/schedule/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getSchedule, setSchedule } from '@/lib/services/backup-destinations.service';
|
||||
import { backupScheduleSchema } from '@/lib/validators/backup-destinations';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/** Read the global automated-backup schedule. Super-admin only. */
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.schedule.get');
|
||||
return NextResponse.json({ data: { schedule: await getSchedule() } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** Set the global automated-backup schedule (off | daily | weekly). Super-admin. */
|
||||
export const PUT = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.schedule.set');
|
||||
const { schedule } = await parseBody(req, backupScheduleSchema);
|
||||
await setSchedule(schedule, ctx.userId);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'backup_schedule',
|
||||
entityId: 'global',
|
||||
severity: 'warning',
|
||||
metadata: { schedule },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: { schedule } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Download, Database, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
Download,
|
||||
Database,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
HardDriveDownload,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -94,6 +101,18 @@ export function BackupAdminPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFullBundle() {
|
||||
// Streams a tar (db.dump + every blob + manifest.json) straight to disk via
|
||||
// the GET endpoint (cookie auth). The server assembles the bundle before
|
||||
// the first byte arrives, so the browser download sits "pending" for a
|
||||
// moment on large datasets — flag that so it doesn't look stuck.
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
toast.info(
|
||||
'Preparing full backup — your download starts once the server finishes assembling it.',
|
||||
);
|
||||
triggerUrlDownload('/api/v1/admin/backup/export', `pn-crm-backup-${stamp}.tar`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -126,6 +145,32 @@ export function BackupAdminPanel() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<HardDriveDownload className="h-4 w-4" aria-hidden />
|
||||
Full disaster-recovery export
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Bundles the database dump <em>and every stored file</em> (documents, berth PDFs,
|
||||
brochures, GDPR exports) into one <code>.tar</code> and downloads it to this computer.
|
||||
Use this as an offsite, storage-backend-independent backup.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadFullBundle}>
|
||||
<HardDriveDownload className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Download full backup
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
The archive contains <code>db.dump</code>, <code>blobs/<key></code> for every file,
|
||||
and a <code>manifest.json</code> with a SHA-256 per object for restore-side verification.
|
||||
Unlike the DB-only backup above, this does not depend on the active storage backend
|
||||
surviving. Restore steps live in <code>docs/backup-restore-runbook.md</code>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">History</CardTitle>
|
||||
|
||||
604
src/components/admin/backup-destinations-card.tsx
Normal file
604
src/components/admin/backup-destinations-card.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Server,
|
||||
Cloud,
|
||||
FolderTree,
|
||||
Trash2,
|
||||
Pencil,
|
||||
PlugZap,
|
||||
UploadCloud,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
type DestType = 'sftp' | 's3' | 'filesystem';
|
||||
type Schedule = 'off' | 'daily' | 'weekly';
|
||||
|
||||
interface Destination {
|
||||
id: string;
|
||||
name: string;
|
||||
type: DestType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
retentionCount: number | null;
|
||||
encryptBundle: boolean;
|
||||
encryptionKeyIsSet: boolean;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
lastError: string | null;
|
||||
lastBackupBytes: number | null;
|
||||
}
|
||||
|
||||
const TYPE_META: Record<DestType, { label: string; icon: typeof Server; hint: string }> = {
|
||||
sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' },
|
||||
s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' },
|
||||
filesystem: {
|
||||
label: 'Mounted path / NAS',
|
||||
icon: FolderTree,
|
||||
hint: 'A directory this server can write to.',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
ok: 'text-emerald-700',
|
||||
failed: 'text-rose-700',
|
||||
};
|
||||
|
||||
function formatBytes(n: number | null): string {
|
||||
if (n === null) return '—';
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function BackupDestinationsCard() {
|
||||
const [destinations, setDestinations] = useState<Destination[]>([]);
|
||||
const [schedule, setSchedule] = useState<Schedule>('off');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Destination | 'new' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, s] = await Promise.all([
|
||||
apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'),
|
||||
apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'),
|
||||
]);
|
||||
setDestinations(d.data);
|
||||
setSchedule(s.data.schedule);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSchedule(value: Schedule) {
|
||||
setSchedule(value);
|
||||
try {
|
||||
await apiFetch('/api/v1/admin/backup/schedule', {
|
||||
method: 'PUT',
|
||||
body: { schedule: value },
|
||||
});
|
||||
toast.success(
|
||||
value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
void load();
|
||||
}
|
||||
}
|
||||
|
||||
async function test(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' });
|
||||
toast.success('Connection OK');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.');
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' });
|
||||
toast.success('Backup pushed');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
await load();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('Delete this backup destination? This does not delete already-pushed backups.'))
|
||||
return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' });
|
||||
toast.success('Destination deleted');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<UploadCloud className="h-4 w-4" aria-hidden />
|
||||
Automated backup destinations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Where scheduled backups are pushed. Each destination receives the same full bundle
|
||||
(database + every file) you can download above.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setEditing('new')}>
|
||||
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Add destination
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="backup-schedule" className="text-sm">
|
||||
Schedule
|
||||
</Label>
|
||||
<Select value={schedule} onValueChange={(v) => void changeSchedule(v as Schedule)}>
|
||||
<SelectTrigger id="backup-schedule" className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
<SelectItem value="daily">Daily (02:00)</SelectItem>
|
||||
<SelectItem value="weekly">Weekly (Sun 02:00)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pushes to every enabled destination below.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Loading…
|
||||
</div>
|
||||
) : destinations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No destinations yet. Add one to enable automated offsite backups.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{destinations.map((d) => {
|
||||
const Icon = TYPE_META[d.type].icon;
|
||||
return (
|
||||
<li key={d.id} className="flex items-center justify-between gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium truncate">{d.name}</span>
|
||||
{d.encryptBundle && (
|
||||
<ShieldCheck
|
||||
className="h-3.5 w-3.5 text-emerald-600"
|
||||
aria-label="Encrypted"
|
||||
/>
|
||||
)}
|
||||
{!d.enabled && (
|
||||
<span className="text-xs rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{TYPE_META[d.type].label}
|
||||
{d.lastStatus && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className={STATUS_TONE[d.lastStatus] ?? ''}>
|
||||
{d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'}
|
||||
</span>
|
||||
{d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`}
|
||||
{d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`}
|
||||
</>
|
||||
)}
|
||||
{d.lastStatus === 'failed' && d.lastError && (
|
||||
<span className="block text-rose-700 truncate">{d.lastError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void test(d.id)}
|
||||
>
|
||||
<PlugZap className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void runNow(d.id)}
|
||||
>
|
||||
{busyId === d.id ? (
|
||||
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<UploadCloud className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Back up now
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => setEditing(d)}>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void remove(d.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-rose-600" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{editing && (
|
||||
<DestinationDialog
|
||||
destination={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── add/edit dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DialogProps {
|
||||
destination: Destination | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function DestinationDialog({ destination, onClose, onSaved }: DialogProps) {
|
||||
const isEdit = Boolean(destination);
|
||||
const cfg = (destination?.config ?? {}) as Record<string, unknown>;
|
||||
const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : '');
|
||||
const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : '');
|
||||
|
||||
const [name, setName] = useState(destination?.name ?? '');
|
||||
const [type, setType] = useState<DestType>(destination?.type ?? 'sftp');
|
||||
const [enabled, setEnabled] = useState(destination?.enabled ?? true);
|
||||
const [retention, setRetention] = useState(
|
||||
destination?.retentionCount != null ? String(destination.retentionCount) : '',
|
||||
);
|
||||
const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false);
|
||||
const [encryptionKey, setEncryptionKey] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Config fields (controlled). Secrets start blank on edit (kept server-side).
|
||||
const [c, setC] = useState<Record<string, string>>({
|
||||
directory: str('directory'),
|
||||
host: str('host'),
|
||||
port: num('port'),
|
||||
username: str('username'),
|
||||
password: '',
|
||||
privateKey: '',
|
||||
passphrase: '',
|
||||
remoteDir: str('remoteDir'),
|
||||
hostFingerprint: str('hostFingerprint'),
|
||||
endpoint: str('endpoint'),
|
||||
region: str('region'),
|
||||
bucket: str('bucket'),
|
||||
accessKey: str('accessKey'),
|
||||
secretKey: '',
|
||||
prefix: str('prefix'),
|
||||
});
|
||||
const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
if (type === 'filesystem') return { directory: c.directory };
|
||||
if (type === 'sftp') {
|
||||
return {
|
||||
host: c.host,
|
||||
...(c.port ? { port: Number(c.port) } : {}),
|
||||
username: c.username,
|
||||
...(c.password ? { password: c.password } : {}),
|
||||
...(c.privateKey ? { privateKey: c.privateKey } : {}),
|
||||
...(c.passphrase ? { passphrase: c.passphrase } : {}),
|
||||
remoteDir: c.remoteDir,
|
||||
...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
endpoint: c.endpoint,
|
||||
...(c.region ? { region: c.region } : {}),
|
||||
bucket: c.bucket,
|
||||
accessKey: c.accessKey,
|
||||
...(c.secretKey ? { secretKey: c.secretKey } : {}),
|
||||
...(c.prefix ? { prefix: c.prefix } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
config: buildConfig(),
|
||||
retentionCount: retention ? Number(retention) : null,
|
||||
encryptBundle,
|
||||
...(encryptionKey ? { encryptionKey } : {}),
|
||||
};
|
||||
if (isEdit && destination) {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/backup/destinations', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
toast.success(isEdit ? 'Destination updated' : 'Destination added');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : '';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit destination' : 'Add backup destination'}</DialogTitle>
|
||||
<DialogDescription>{TYPE_META[type].hint}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field label="Name">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Hetzner box"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<Select value={type} onValueChange={(v) => setType(v as DestType)} disabled={isEdit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sftp">SFTP / SSH server</SelectItem>
|
||||
<SelectItem value="s3">S3-compatible</SelectItem>
|
||||
<SelectItem value="filesystem">Mounted path / NAS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{type === 'filesystem' && (
|
||||
<Field label="Directory">
|
||||
<Input
|
||||
value={c.directory}
|
||||
onChange={(e) => set('directory', e.target.value)}
|
||||
placeholder="/mnt/backups"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === 'sftp' && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<Field label="Host">
|
||||
<Input
|
||||
value={c.host}
|
||||
onChange={(e) => set('host', e.target.value)}
|
||||
placeholder="backups.example.com"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Port">
|
||||
<Input
|
||||
value={c.port}
|
||||
onChange={(e) => set('port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Username">
|
||||
<Input value={c.username} onChange={(e) => set('username', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<Input
|
||||
type="password"
|
||||
value={c.password}
|
||||
onChange={(e) => set('password', e.target.value)}
|
||||
placeholder={secretPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Private key (optional, instead of password)">
|
||||
<textarea
|
||||
className="w-full rounded-md border px-2 py-1.5 text-xs font-mono"
|
||||
rows={3}
|
||||
value={c.privateKey}
|
||||
onChange={(e) => set('privateKey', e.target.value)}
|
||||
placeholder={secretPlaceholder || '-----BEGIN OPENSSH PRIVATE KEY-----'}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Remote directory">
|
||||
<Input
|
||||
value={c.remoteDir}
|
||||
onChange={(e) => set('remoteDir', e.target.value)}
|
||||
placeholder="/srv/pn-crm-backups"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Host key fingerprint (optional, sha256 — pins the server)">
|
||||
<Input
|
||||
value={c.hostFingerprint}
|
||||
onChange={(e) => set('hostFingerprint', e.target.value)}
|
||||
placeholder="aa:bb:cc… or hex"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 's3' && (
|
||||
<>
|
||||
<Field label="Endpoint">
|
||||
<Input
|
||||
value={c.endpoint}
|
||||
onChange={(e) => set('endpoint', e.target.value)}
|
||||
placeholder="https://s3.us-west.example.com"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Bucket">
|
||||
<Input value={c.bucket} onChange={(e) => set('bucket', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Region (optional)">
|
||||
<Input
|
||||
value={c.region}
|
||||
onChange={(e) => set('region', e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Access key">
|
||||
<Input value={c.accessKey} onChange={(e) => set('accessKey', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Secret key">
|
||||
<Input
|
||||
type="password"
|
||||
value={c.secretKey}
|
||||
onChange={(e) => set('secretKey', e.target.value)}
|
||||
placeholder={secretPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Prefix (optional)">
|
||||
<Input
|
||||
value={c.prefix}
|
||||
onChange={(e) => set('prefix', e.target.value)}
|
||||
placeholder="crm-backups/"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Keep last N (blank = all)">
|
||||
<Input
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(e.target.value)}
|
||||
placeholder="7"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
Enabled (auto-pushed)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={encryptBundle} onCheckedChange={setEncryptBundle} />
|
||||
Encrypt the bundle before sending (AES-256)
|
||||
</label>
|
||||
{encryptBundle && (
|
||||
<Field label="Passphrase (needed to restore — store it safely)">
|
||||
<Input
|
||||
type="password"
|
||||
value={encryptionKey}
|
||||
onChange={(e) => setEncryptionKey(e.target.value)}
|
||||
placeholder={
|
||||
destination?.encryptionKeyIsSet ? 'unchanged — leave blank to keep' : ''
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={saving || !name}>
|
||||
{saving && <Loader2 className="me-1.5 h-4 w-4 animate-spin" aria-hidden />}
|
||||
{isEdit ? 'Save changes' : 'Add destination'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,10 @@ export type AuditAction =
|
||||
// Branding (port logo upload pipeline).
|
||||
| 'branding.logo.uploaded'
|
||||
| 'branding.logo.archived'
|
||||
// Full-bundle backup export (DB dump + every blob) downloaded by an
|
||||
// operator. A cross-tenant data egress — logged at warning severity so the
|
||||
// audit filter surfaces it distinctly from routine reads.
|
||||
| 'backup_export'
|
||||
// System / background events.
|
||||
| 'webhook_delivered'
|
||||
| 'webhook_failed'
|
||||
|
||||
23
src/lib/db/migrations/0091_backup_destinations.sql
Normal file
23
src/lib/db/migrations/0091_backup_destinations.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Admin-configurable backup destinations (Phase 4b).
|
||||
-- Each row is a place scheduled/manual full-bundle backups are pushed to.
|
||||
-- Secrets inside `config` are AES-GCM-encrypted by the application layer.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "backup_destinations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"retention_count" integer,
|
||||
"encrypt_bundle" boolean DEFAULT false NOT NULL,
|
||||
"encryption_key_encrypted" text,
|
||||
"last_run_at" timestamptz,
|
||||
"last_status" text,
|
||||
"last_error" text,
|
||||
"last_backup_bytes" bigint,
|
||||
"created_at" timestamptz DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamptz DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_backup_destinations_enabled"
|
||||
ON "backup_destinations" ("enabled");
|
||||
@@ -371,3 +371,43 @@ export const backupJobs = pgTable(
|
||||
|
||||
export type BackupJob = typeof backupJobs.$inferSelect;
|
||||
export type NewBackupJob = typeof backupJobs.$inferInsert;
|
||||
|
||||
/**
|
||||
* Admin-configurable destinations that scheduled/manual backups are pushed to.
|
||||
* Each row transports the exact full-bundle tar produced by
|
||||
* `createFullBackupTar()` (db.dump + blobs + manifest) — see
|
||||
* docs/superpowers/specs/2026-06-04-backup-destinations-design.md.
|
||||
*
|
||||
* `config` holds the type-specific connection settings; any secret inside it
|
||||
* (SFTP password / private key, S3 secret key) is AES-GCM-encrypted via
|
||||
* `@/lib/utils/encryption` before storage and never returned raw (the API
|
||||
* surfaces only `*IsSet` markers, mirroring the send-from-accounts pattern).
|
||||
*/
|
||||
export const backupDestinations = pgTable(
|
||||
'backup_destinations',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(), // 'sftp' | 's3' | 'filesystem'
|
||||
enabled: boolean('enabled').notNull().default(false),
|
||||
config: jsonb('config').notNull().default({}),
|
||||
/** Keep last N bundles at this destination; null = keep all. */
|
||||
retentionCount: integer('retention_count'),
|
||||
/** Opt-in client-side AES-256 encryption of the bundle before push. */
|
||||
encryptBundle: boolean('encrypt_bundle').notNull().default(false),
|
||||
/** The bundle passphrase, itself AES-GCM-encrypted at rest. */
|
||||
encryptionKeyEncrypted: text('encryption_key_encrypted'),
|
||||
lastRunAt: timestamp('last_run_at', { withTimezone: true }),
|
||||
lastStatus: text('last_status'), // 'ok' | 'failed'
|
||||
lastError: text('last_error'),
|
||||
lastBackupBytes: bigint('last_backup_bytes', { mode: 'number' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_backup_destinations_enabled').on(table.enabled)],
|
||||
);
|
||||
|
||||
export type BackupDestination = typeof backupDestinations.$inferSelect;
|
||||
export type NewBackupDestination = typeof backupDestinations.$inferInsert;
|
||||
|
||||
@@ -38,6 +38,17 @@ export const maintenanceWorker = new Worker(
|
||||
await refreshRates();
|
||||
break;
|
||||
}
|
||||
case 'database-backup': {
|
||||
// Scheduled full-bundle backup pushed to every enabled destination.
|
||||
// No-op until an admin turns the schedule on AND enables a destination
|
||||
// (`backup_schedule` setting + `backup_destinations`). Replaces the
|
||||
// previous silent no-op (this case did not exist before).
|
||||
const { runScheduledBackupPush } =
|
||||
await import('@/lib/services/backup-destinations.service');
|
||||
const summary = await runScheduledBackupPush();
|
||||
logger.info(summary, 'Scheduled backup push complete');
|
||||
break;
|
||||
}
|
||||
case 'form-expiry-check': {
|
||||
const result = await db
|
||||
.update(formSubmissions)
|
||||
|
||||
458
src/lib/services/backup-destinations.service.ts
Normal file
458
src/lib/services/backup-destinations.service.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Admin-configurable backup destinations — service layer.
|
||||
* See docs/superpowers/specs/2026-06-04-backup-destinations-design.md.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - CRUD over `backup_destinations` with secret encryption at rest + masking on
|
||||
* read (mirrors the send-from-accounts pattern: API returns only `*IsSet`).
|
||||
* - test / manual-push / prune to a destination.
|
||||
* - scheduled push to all enabled destinations, with failure alerting.
|
||||
*
|
||||
* Every push transports the exact SHA-verified tar from `createFullBackupTar()`
|
||||
* — the same bundle admins download — optionally AES-256 encrypted first.
|
||||
*/
|
||||
|
||||
import { unlink } from 'node:fs/promises';
|
||||
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
backupDestinations,
|
||||
backupJobs,
|
||||
systemSettings,
|
||||
type BackupDestination,
|
||||
} from '@/lib/db/schema/system';
|
||||
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
import { createFullBackupTar } from '@/lib/services/backup-export.service';
|
||||
import { decrypt, encrypt } from '@/lib/utils/encryption';
|
||||
|
||||
import {
|
||||
buildTransport,
|
||||
type BackupDestinationType,
|
||||
type BackupTransport,
|
||||
} from './backup-destinations';
|
||||
import { encryptFileToFile } from './backup-destinations/bundle-encryption';
|
||||
|
||||
// ─── secret config handling ─────────────────────────────────────────────────
|
||||
|
||||
const SECRET_FIELDS: Record<BackupDestinationType, string[]> = {
|
||||
sftp: ['password', 'privateKey', 'passphrase'],
|
||||
s3: ['secretKey'],
|
||||
filesystem: [],
|
||||
};
|
||||
|
||||
type Cfg = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Prepare an incoming config for storage: encrypt every secret field that
|
||||
* carries a new non-empty value; for blank/absent secret fields, carry over the
|
||||
* already-encrypted value from `existing` (so "leave unchanged" works on edit).
|
||||
* Non-secret fields are taken from `incoming` (falling back to `existing`).
|
||||
*/
|
||||
export function serializeConfig(type: BackupDestinationType, incoming: Cfg, existing?: Cfg): Cfg {
|
||||
const secrets = new Set(SECRET_FIELDS[type] ?? []);
|
||||
const out: Cfg = {};
|
||||
// Non-secret fields from incoming (or carry existing if omitted).
|
||||
for (const [k, v] of Object.entries(incoming)) {
|
||||
if (!secrets.has(k)) out[k] = v;
|
||||
}
|
||||
for (const field of secrets) {
|
||||
const incomingVal = incoming[field];
|
||||
if (typeof incomingVal === 'string' && incomingVal.length > 0) {
|
||||
out[field] = encrypt(incomingVal);
|
||||
} else if (existing && typeof existing[field] === 'string') {
|
||||
out[field] = existing[field];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decrypt the secret fields of a stored config back to plaintext for transport use. */
|
||||
export function decryptConfig(type: BackupDestinationType, stored: Cfg): Cfg {
|
||||
const secrets = new Set(SECRET_FIELDS[type] ?? []);
|
||||
const out: Cfg = { ...stored };
|
||||
for (const field of secrets) {
|
||||
const v = stored[field];
|
||||
if (typeof v === 'string' && v.length > 0) {
|
||||
try {
|
||||
out[field] = decrypt(v);
|
||||
} catch (err) {
|
||||
logger.error({ err, field }, 'Failed to decrypt backup destination secret');
|
||||
delete out[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strip secret fields from a stored config and expose `<field>IsSet` markers. */
|
||||
export function maskConfig(type: BackupDestinationType, stored: Cfg): Cfg {
|
||||
const secrets = new Set(SECRET_FIELDS[type] ?? []);
|
||||
const out: Cfg = {};
|
||||
for (const [k, v] of Object.entries(stored)) {
|
||||
if (!secrets.has(k)) out[k] = v;
|
||||
}
|
||||
for (const field of secrets) {
|
||||
out[`${field}IsSet`] =
|
||||
typeof stored[field] === 'string' && (stored[field] as string).length > 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface MaskedDestination {
|
||||
id: string;
|
||||
name: string;
|
||||
type: BackupDestinationType;
|
||||
enabled: boolean;
|
||||
config: Cfg;
|
||||
retentionCount: number | null;
|
||||
encryptBundle: boolean;
|
||||
encryptionKeyIsSet: boolean;
|
||||
lastRunAt: Date | null;
|
||||
lastStatus: string | null;
|
||||
lastError: string | null;
|
||||
lastBackupBytes: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function mask(row: BackupDestination): MaskedDestination {
|
||||
const type = row.type as BackupDestinationType;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type,
|
||||
enabled: row.enabled,
|
||||
config: maskConfig(type, (row.config ?? {}) as Cfg),
|
||||
retentionCount: row.retentionCount,
|
||||
encryptBundle: row.encryptBundle,
|
||||
encryptionKeyIsSet: Boolean(row.encryptionKeyEncrypted),
|
||||
lastRunAt: row.lastRunAt,
|
||||
lastStatus: row.lastStatus,
|
||||
lastError: row.lastError,
|
||||
lastBackupBytes: row.lastBackupBytes,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── schedule ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type BackupSchedule = 'off' | 'daily' | 'weekly';
|
||||
|
||||
/** Whether a scheduled push should run for `date` under `schedule`. */
|
||||
export function isScheduleDue(schedule: BackupSchedule, date: Date): boolean {
|
||||
if (schedule === 'off') return false;
|
||||
if (schedule === 'daily') return true;
|
||||
return date.getUTCDay() === 0; // weekly → Sundays
|
||||
}
|
||||
|
||||
export async function getSchedule(): Promise<BackupSchedule> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(and(eq(systemSettings.key, 'backup_schedule'), isNull(systemSettings.portId)));
|
||||
const v = row?.value;
|
||||
return v === 'daily' || v === 'weekly' ? v : 'off';
|
||||
}
|
||||
|
||||
export async function setSchedule(value: BackupSchedule, userId: string): Promise<void> {
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, 'backup_schedule'), isNull(systemSettings.portId)),
|
||||
});
|
||||
if (existing) {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({ value, updatedBy: userId, updatedAt: new Date() })
|
||||
.where(and(eq(systemSettings.key, 'backup_schedule'), isNull(systemSettings.portId)));
|
||||
} else {
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({ key: 'backup_schedule', value, portId: null, updatedBy: userId });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listDestinations(): Promise<MaskedDestination[]> {
|
||||
const rows = await db.query.backupDestinations.findMany({
|
||||
orderBy: (d, { asc }) => [asc(d.createdAt)],
|
||||
});
|
||||
return rows.map(mask);
|
||||
}
|
||||
|
||||
export interface DestinationInput {
|
||||
name: string;
|
||||
type: BackupDestinationType;
|
||||
enabled?: boolean;
|
||||
config: Cfg;
|
||||
retentionCount?: number | null;
|
||||
encryptBundle?: boolean;
|
||||
/** Plaintext bundle passphrase; encrypted at rest. Blank = leave unchanged. */
|
||||
encryptionKey?: string;
|
||||
}
|
||||
|
||||
export async function createDestination(input: DestinationInput): Promise<MaskedDestination> {
|
||||
const [row] = await db
|
||||
.insert(backupDestinations)
|
||||
.values({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
enabled: input.enabled ?? false,
|
||||
config: serializeConfig(input.type, input.config),
|
||||
retentionCount: input.retentionCount ?? null,
|
||||
encryptBundle: input.encryptBundle ?? false,
|
||||
encryptionKeyEncrypted:
|
||||
input.encryptionKey && input.encryptionKey.length > 0 ? encrypt(input.encryptionKey) : null,
|
||||
})
|
||||
.returning();
|
||||
if (!row) throw new Error('Failed to create backup destination');
|
||||
return mask(row);
|
||||
}
|
||||
|
||||
export async function updateDestination(
|
||||
id: string,
|
||||
input: DestinationInput,
|
||||
): Promise<MaskedDestination> {
|
||||
const existing = await db.query.backupDestinations.findFirst({
|
||||
where: eq(backupDestinations.id, id),
|
||||
});
|
||||
if (!existing) throw new Error('Backup destination not found');
|
||||
|
||||
const [row] = await db
|
||||
.update(backupDestinations)
|
||||
.set({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
config: serializeConfig(input.type, input.config, (existing.config ?? {}) as Cfg),
|
||||
retentionCount: input.retentionCount ?? null,
|
||||
encryptBundle: input.encryptBundle ?? false,
|
||||
encryptionKeyEncrypted:
|
||||
input.encryptionKey && input.encryptionKey.length > 0
|
||||
? encrypt(input.encryptionKey)
|
||||
: existing.encryptionKeyEncrypted,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backupDestinations.id, id))
|
||||
.returning();
|
||||
if (!row) throw new Error('Failed to update backup destination');
|
||||
return mask(row);
|
||||
}
|
||||
|
||||
export async function deleteDestination(id: string): Promise<void> {
|
||||
await db.delete(backupDestinations).where(eq(backupDestinations.id, id));
|
||||
}
|
||||
|
||||
// ─── transport helpers ────────────────────────────────────────────────────
|
||||
|
||||
function transportFor(row: BackupDestination): BackupTransport {
|
||||
const type = row.type as BackupDestinationType;
|
||||
return buildTransport(type, decryptConfig(type, (row.config ?? {}) as Cfg));
|
||||
}
|
||||
|
||||
export async function testDestination(id: string): Promise<void> {
|
||||
const row = await db.query.backupDestinations.findFirst({
|
||||
where: eq(backupDestinations.id, id),
|
||||
});
|
||||
if (!row) throw new Error('Backup destination not found');
|
||||
await transportFor(row).test();
|
||||
}
|
||||
|
||||
// ─── push ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PushOpts {
|
||||
/** Reuse an already-assembled tar (scheduled push assembles once for all). */
|
||||
tarPath?: string;
|
||||
filename?: string;
|
||||
trigger: 'manual' | 'cron';
|
||||
triggeredBy?: string | null;
|
||||
}
|
||||
|
||||
export async function pushBackupToDestination(
|
||||
id: string,
|
||||
opts: PushOpts,
|
||||
): Promise<{
|
||||
bytes: number;
|
||||
remoteRef: string;
|
||||
}> {
|
||||
const row = await db.query.backupDestinations.findFirst({
|
||||
where: eq(backupDestinations.id, id),
|
||||
});
|
||||
if (!row) throw new Error('Backup destination not found');
|
||||
|
||||
const transport = transportFor(row);
|
||||
let tarPath = opts.tarPath;
|
||||
let filename = opts.filename;
|
||||
let ownTar: (() => Promise<void>) | null = null;
|
||||
let encPath: string | null = null;
|
||||
|
||||
try {
|
||||
if (!tarPath || !filename) {
|
||||
const made = await createFullBackupTar();
|
||||
tarPath = made.tarPath;
|
||||
filename = made.filename;
|
||||
ownTar = made.cleanup;
|
||||
}
|
||||
|
||||
// Optional client-side encryption before the bytes leave this server.
|
||||
let uploadPath = tarPath;
|
||||
let remoteName = filename;
|
||||
if (row.encryptBundle) {
|
||||
if (!row.encryptionKeyEncrypted) {
|
||||
throw new Error('Destination has encryption enabled but no passphrase configured');
|
||||
}
|
||||
const passphrase = decrypt(row.encryptionKeyEncrypted);
|
||||
encPath = `${tarPath}.enc`;
|
||||
await encryptFileToFile(tarPath, encPath, passphrase);
|
||||
uploadPath = encPath;
|
||||
remoteName = `${filename}.enc`;
|
||||
}
|
||||
|
||||
const { bytes, remoteRef } = await transport.push(uploadPath, remoteName);
|
||||
await transport.prune(row.retentionCount).catch((err) => {
|
||||
logger.warn({ err, destinationId: id }, 'Backup prune failed (push succeeded)');
|
||||
});
|
||||
|
||||
await db
|
||||
.update(backupDestinations)
|
||||
.set({
|
||||
lastRunAt: new Date(),
|
||||
lastStatus: 'ok',
|
||||
lastError: null,
|
||||
lastBackupBytes: bytes,
|
||||
})
|
||||
.where(eq(backupDestinations.id, id));
|
||||
|
||||
await db.insert(backupJobs).values({
|
||||
status: 'completed',
|
||||
trigger: opts.trigger,
|
||||
triggeredBy: opts.triggeredBy ?? null,
|
||||
sizeBytes: bytes,
|
||||
storagePath: remoteRef,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
logger.info({ destinationId: id, bytes, remoteRef }, 'Backup pushed to destination');
|
||||
return { bytes, remoteRef };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'unknown';
|
||||
await db
|
||||
.update(backupDestinations)
|
||||
.set({ lastRunAt: new Date(), lastStatus: 'failed', lastError: message })
|
||||
.where(eq(backupDestinations.id, id));
|
||||
await db
|
||||
.insert(backupJobs)
|
||||
.values({
|
||||
status: 'failed',
|
||||
trigger: opts.trigger,
|
||||
triggeredBy: opts.triggeredBy ?? null,
|
||||
errorMessage: `[${row.name}] ${message}`,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.catch(() => {});
|
||||
await notifyBackupFailure(row.name, message, opts.trigger);
|
||||
throw err;
|
||||
} finally {
|
||||
if (encPath) await unlink(encPath).catch(() => {});
|
||||
if (ownTar) await ownTar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled push: assemble the bundle ONCE and fan it out to every enabled
|
||||
* destination. Per-destination failures are isolated (one bad server doesn't
|
||||
* skip the others) and alerted.
|
||||
*/
|
||||
export async function runScheduledBackupPush(now = new Date()): Promise<{
|
||||
ran: boolean;
|
||||
pushed: number;
|
||||
failed: number;
|
||||
}> {
|
||||
const schedule = await getSchedule();
|
||||
if (!isScheduleDue(schedule, now)) {
|
||||
logger.info({ schedule }, 'Scheduled backup not due');
|
||||
return { ran: false, pushed: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const enabled = await db.query.backupDestinations.findMany({
|
||||
where: eq(backupDestinations.enabled, true),
|
||||
});
|
||||
if (enabled.length === 0) {
|
||||
logger.warn('Backup schedule is on but no destinations are enabled');
|
||||
return { ran: false, pushed: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const bundle = await createFullBackupTar();
|
||||
let pushed = 0;
|
||||
let failed = 0;
|
||||
try {
|
||||
for (const dest of enabled) {
|
||||
try {
|
||||
await pushBackupToDestination(dest.id, {
|
||||
tarPath: bundle.tarPath,
|
||||
filename: bundle.filename,
|
||||
trigger: 'cron',
|
||||
});
|
||||
pushed += 1;
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
logger.error({ err, destinationId: dest.id }, 'Scheduled push to destination failed');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await bundle.cleanup();
|
||||
}
|
||||
logger.info({ pushed, failed, total: enabled.length }, 'Scheduled backup push complete');
|
||||
return { ran: true, pushed, failed };
|
||||
}
|
||||
|
||||
// ─── failure alerting ────────────────────────────────────────────────────
|
||||
|
||||
async function notifyBackupFailure(
|
||||
destinationName: string,
|
||||
message: string,
|
||||
trigger: 'manual' | 'cron',
|
||||
): Promise<void> {
|
||||
// Guaranteed signal: an error-severity audit row (visible in /admin/audit).
|
||||
await createAuditLog({
|
||||
userId: null,
|
||||
portId: null,
|
||||
action: 'job_failed',
|
||||
entityType: 'backup_destination',
|
||||
entityId: destinationName,
|
||||
severity: 'error',
|
||||
source: trigger === 'cron' ? 'cron' : 'job',
|
||||
metadata: { destination: destinationName, error: message },
|
||||
});
|
||||
|
||||
// Best-effort: in-app system alert to every super-admin (per their port).
|
||||
try {
|
||||
const admins = await db
|
||||
.select({ userId: userProfiles.userId, portId: userPortRoles.portId })
|
||||
.from(userProfiles)
|
||||
.innerJoin(userPortRoles, eq(userPortRoles.userId, userProfiles.userId))
|
||||
.where(eq(userProfiles.isSuperAdmin, true));
|
||||
const seen = new Set<string>();
|
||||
for (const a of admins) {
|
||||
const key = `${a.userId}:${a.portId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await createNotification({
|
||||
portId: a.portId,
|
||||
userId: a.userId,
|
||||
type: 'system_alert',
|
||||
title: 'Backup push failed',
|
||||
description: `Backup to "${destinationName}" failed: ${message}`,
|
||||
dedupeKey: `backup-fail:${destinationName}`,
|
||||
cooldownMs: 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to notify super-admins of backup failure');
|
||||
}
|
||||
}
|
||||
109
src/lib/services/backup-destinations/bundle-encryption.ts
Normal file
109
src/lib/services/backup-destinations/bundle-encryption.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Opt-in client-side encryption for backup bundles
|
||||
* (docs/superpowers/specs/2026-06-04-backup-destinations-design.md).
|
||||
*
|
||||
* When a destination has `encryptBundle` on, the tar is encrypted to
|
||||
* `<name>.tar.enc` before it leaves this server, so a compromised destination
|
||||
* (untrusted SFTP host, third-party bucket) never holds raw signed contracts +
|
||||
* GDPR data.
|
||||
*
|
||||
* Format (AES-256-GCM, scrypt KDF):
|
||||
*
|
||||
* ┌────────┬──────────┬──────────┬──────────────┬──────────┐
|
||||
* │ magic │ salt │ iv │ ciphertext … │ authTag │
|
||||
* │ 5 bytes│ 16 bytes │ 12 bytes │ (streamed) │ 16 bytes │
|
||||
* └────────┴──────────┴──────────┴──────────────┴──────────┘
|
||||
*
|
||||
* Streaming throughout (memory stays O(chunk)). The auth tag is written last
|
||||
* because GCM only produces it after the final block; decryption reads it from
|
||||
* the file tail first, then streams the ciphertext through the decipher.
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scrypt = promisify(scryptCb);
|
||||
|
||||
const MAGIC = Buffer.from('PNBK1', 'ascii'); // 5 bytes
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
const HEADER_LEN = MAGIC.length + SALT_LEN + IV_LEN; // 33
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Buffer): Promise<Buffer> {
|
||||
return (await scrypt(passphrase, salt, 32)) as Buffer;
|
||||
}
|
||||
|
||||
/** Encrypt `srcPath` → `destPath` with a passphrase-derived AES-256-GCM key. */
|
||||
export async function encryptFileToFile(
|
||||
srcPath: string,
|
||||
destPath: string,
|
||||
passphrase: string,
|
||||
): Promise<void> {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const key = await deriveKey(passphrase, salt);
|
||||
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const out = createWriteStream(destPath);
|
||||
out.write(Buffer.concat([MAGIC, salt, iv]));
|
||||
|
||||
// Pipe plaintext → cipher → file, writing to `out` by hand (rather than
|
||||
// letting pipeline end it) so we can append the auth tag once the cipher has
|
||||
// flushed its final block.
|
||||
await pipeline(createReadStream(srcPath), cipher, async (source) => {
|
||||
for await (const chunk of source) {
|
||||
if (!out.write(chunk as Buffer)) {
|
||||
await new Promise<void>((resolve) => out.once('drain', () => resolve()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
out.write(cipher.getAuthTag());
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/** Decrypt a file produced by {@link encryptFileToFile}. Throws on wrong key / tamper. */
|
||||
export async function decryptFileToFile(
|
||||
srcPath: string,
|
||||
destPath: string,
|
||||
passphrase: string,
|
||||
): Promise<void> {
|
||||
const { size } = await stat(srcPath);
|
||||
if (size < HEADER_LEN + TAG_LEN) {
|
||||
throw new Error('Encrypted backup is too small / not a PNBK1 bundle');
|
||||
}
|
||||
|
||||
// Read the fixed header + the trailing auth tag.
|
||||
const fh = await open(srcPath, 'r');
|
||||
try {
|
||||
const header = Buffer.alloc(HEADER_LEN);
|
||||
await fh.read(header, 0, HEADER_LEN, 0);
|
||||
if (!header.subarray(0, MAGIC.length).equals(MAGIC)) {
|
||||
throw new Error('Not a PNBK1 encrypted backup (bad magic)');
|
||||
}
|
||||
const salt = header.subarray(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
const iv = header.subarray(MAGIC.length + SALT_LEN, HEADER_LEN);
|
||||
|
||||
const tag = Buffer.alloc(TAG_LEN);
|
||||
await fh.read(tag, 0, TAG_LEN, size - TAG_LEN);
|
||||
|
||||
const key = await deriveKey(passphrase, salt);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
// Stream only the ciphertext region [HEADER_LEN, size - TAG_LEN).
|
||||
const cipherStream = createReadStream(srcPath, {
|
||||
start: HEADER_LEN,
|
||||
end: size - TAG_LEN - 1,
|
||||
});
|
||||
await pipeline(cipherStream, decipher, createWriteStream(destPath));
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
47
src/lib/services/backup-destinations/filesystem.ts
Normal file
47
src/lib/services/backup-destinations/filesystem.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Filesystem backup transport — pushes the bundle to a configured directory
|
||||
* (a mounted volume / NAS share). The simplest destination: no network, just a
|
||||
* path the app can write to.
|
||||
*/
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, copyFile, readdir, stat, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
BACKUP_NAME_PREFIX,
|
||||
sortBundlesNewestFirst,
|
||||
type BackupTransport,
|
||||
type FilesystemDestConfig,
|
||||
} from './types';
|
||||
|
||||
export class FilesystemTransport implements BackupTransport {
|
||||
constructor(private readonly cfg: FilesystemDestConfig) {}
|
||||
|
||||
async test(): Promise<void> {
|
||||
if (!this.cfg.directory) throw new Error('No directory configured');
|
||||
await access(this.cfg.directory, constants.W_OK).catch(() => {
|
||||
throw new Error(`Directory not writable or does not exist: ${this.cfg.directory}`);
|
||||
});
|
||||
const s = await stat(this.cfg.directory);
|
||||
if (!s.isDirectory()) throw new Error(`Not a directory: ${this.cfg.directory}`);
|
||||
}
|
||||
|
||||
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
|
||||
const dest = path.join(this.cfg.directory, remoteName);
|
||||
await copyFile(localPath, dest);
|
||||
const s = await stat(dest);
|
||||
return { remoteRef: dest, bytes: s.size };
|
||||
}
|
||||
|
||||
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
|
||||
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
|
||||
const entries = await readdir(this.cfg.directory);
|
||||
const bundles = sortBundlesNewestFirst(entries.filter((n) => n.startsWith(BACKUP_NAME_PREFIX)));
|
||||
const toDelete = bundles.slice(retentionCount);
|
||||
for (const name of toDelete) {
|
||||
await unlink(path.join(this.cfg.directory, name)).catch(() => {});
|
||||
}
|
||||
return { deleted: toDelete.length };
|
||||
}
|
||||
}
|
||||
36
src/lib/services/backup-destinations/index.ts
Normal file
36
src/lib/services/backup-destinations/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Backup destination transport factory. Given a destination type + its
|
||||
* *decrypted* runtime config, build the matching transport.
|
||||
*/
|
||||
|
||||
import { FilesystemTransport } from './filesystem';
|
||||
import { S3Transport } from './s3';
|
||||
import { SftpTransport } from './sftp';
|
||||
import type {
|
||||
BackupDestinationType,
|
||||
BackupTransport,
|
||||
FilesystemDestConfig,
|
||||
S3DestConfig,
|
||||
SftpDestConfig,
|
||||
} from './types';
|
||||
|
||||
export function buildTransport(
|
||||
type: BackupDestinationType,
|
||||
config: Record<string, unknown>,
|
||||
): BackupTransport {
|
||||
switch (type) {
|
||||
case 'filesystem':
|
||||
return new FilesystemTransport(config as unknown as FilesystemDestConfig);
|
||||
case 'sftp':
|
||||
return new SftpTransport(config as unknown as SftpDestConfig);
|
||||
case 's3':
|
||||
return new S3Transport(config as unknown as S3DestConfig);
|
||||
default:
|
||||
throw new Error(`Unknown backup destination type: ${String(type)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export { FilesystemTransport } from './filesystem';
|
||||
export { SftpTransport } from './sftp';
|
||||
export { S3Transport, parseS3Endpoint } from './s3';
|
||||
export * from './types';
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
102
src/lib/services/backup-destinations/sftp.ts
Normal file
102
src/lib/services/backup-destinations/sftp.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* SFTP/SSH backup transport — pushes the bundle to a remote server over SFTP.
|
||||
* This is the "separate server" destination most deployments will use.
|
||||
*
|
||||
* Host-key handling: when `hostFingerprint` is set, the server's key is verified
|
||||
* against it (sha256, colons/whitespace ignored) and the connection is rejected
|
||||
* on mismatch — defends against MITM. With no fingerprint configured we accept
|
||||
* on first use (TOFU); admins should pin the fingerprint for untrusted networks.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import SftpClient from 'ssh2-sftp-client';
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
import {
|
||||
BACKUP_NAME_PREFIX,
|
||||
sortBundlesNewestFirst,
|
||||
type BackupTransport,
|
||||
type SftpDestConfig,
|
||||
} from './types';
|
||||
|
||||
function normalizeFingerprint(fp: string): string {
|
||||
return fp
|
||||
.replace(/^sha256:/i, '')
|
||||
.replace(/[:\s]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export class SftpTransport implements BackupTransport {
|
||||
constructor(private readonly cfg: SftpDestConfig) {}
|
||||
|
||||
private connectOptions(): SftpClient.ConnectOptions {
|
||||
const expected = this.cfg.hostFingerprint
|
||||
? normalizeFingerprint(this.cfg.hostFingerprint)
|
||||
: null;
|
||||
return {
|
||||
host: this.cfg.host,
|
||||
port: this.cfg.port ?? 22,
|
||||
username: this.cfg.username,
|
||||
...(this.cfg.password ? { password: this.cfg.password } : {}),
|
||||
...(this.cfg.privateKey ? { privateKey: this.cfg.privateKey } : {}),
|
||||
...(this.cfg.passphrase ? { passphrase: this.cfg.passphrase } : {}),
|
||||
// ssh2 calls this with the server's host key; hash + compare to the pin.
|
||||
hostVerifier: (key: Buffer): boolean => {
|
||||
if (!expected) return true;
|
||||
const actual = createHash('sha256').update(key).digest('hex');
|
||||
const ok = actual === expected;
|
||||
if (!ok) logger.error({ host: this.cfg.host }, 'SFTP host-key fingerprint mismatch');
|
||||
return ok;
|
||||
},
|
||||
} as SftpClient.ConnectOptions;
|
||||
}
|
||||
|
||||
private async withClient<T>(fn: (c: SftpClient) => Promise<T>): Promise<T> {
|
||||
const client = new SftpClient();
|
||||
try {
|
||||
await client.connect(this.connectOptions());
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async test(): Promise<void> {
|
||||
await this.withClient(async (c) => {
|
||||
// Ensure the remote dir exists (create recursively if needed) and is usable.
|
||||
const exists = await c.exists(this.cfg.remoteDir);
|
||||
if (!exists) await c.mkdir(this.cfg.remoteDir, true);
|
||||
});
|
||||
}
|
||||
|
||||
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
|
||||
return this.withClient(async (c) => {
|
||||
const exists = await c.exists(this.cfg.remoteDir);
|
||||
if (!exists) await c.mkdir(this.cfg.remoteDir, true);
|
||||
const remotePath = path.posix.join(this.cfg.remoteDir, remoteName);
|
||||
await c.fastPut(localPath, remotePath);
|
||||
const s = await c.stat(remotePath);
|
||||
return { remoteRef: `sftp://${this.cfg.host}${remotePath}`, bytes: s.size };
|
||||
});
|
||||
}
|
||||
|
||||
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
|
||||
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
|
||||
return this.withClient(async (c) => {
|
||||
const list = await c.list(this.cfg.remoteDir);
|
||||
const bundles = sortBundlesNewestFirst(
|
||||
list
|
||||
.filter((e) => e.type === '-' && e.name.startsWith(BACKUP_NAME_PREFIX))
|
||||
.map((e) => e.name),
|
||||
);
|
||||
const toDelete = bundles.slice(retentionCount);
|
||||
for (const name of toDelete) {
|
||||
await c.delete(path.posix.join(this.cfg.remoteDir, name)).catch(() => {});
|
||||
}
|
||||
return { deleted: toDelete.length };
|
||||
});
|
||||
}
|
||||
}
|
||||
60
src/lib/services/backup-destinations/types.ts
Normal file
60
src/lib/services/backup-destinations/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Backup destination transport contract + per-type config shapes.
|
||||
* See docs/superpowers/specs/2026-06-04-backup-destinations-design.md.
|
||||
*
|
||||
* Config shapes here are the *decrypted, runtime* form. Secrets are stored
|
||||
* AES-GCM-encrypted in the `backup_destinations.config` jsonb and decrypted by
|
||||
* the service layer before a transport is constructed.
|
||||
*/
|
||||
|
||||
export type BackupDestinationType = 'sftp' | 's3' | 'filesystem';
|
||||
|
||||
/** Filename prefix every full-bundle tar carries (`createFullBackupTar`). */
|
||||
export const BACKUP_NAME_PREFIX = 'pn-crm-backup-';
|
||||
|
||||
export interface BackupTransport {
|
||||
/** Verify the destination is reachable + writable. Throws on failure. */
|
||||
test(): Promise<void>;
|
||||
/** Upload `localPath` to the destination as `remoteName`. */
|
||||
push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }>;
|
||||
/** Keep the `retentionCount` newest bundles; null = keep all. */
|
||||
prune(retentionCount: number | null): Promise<{ deleted: number }>;
|
||||
}
|
||||
|
||||
export interface FilesystemDestConfig {
|
||||
/** Absolute path to a mounted volume / NAS directory the app can write to. */
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface SftpDestConfig {
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
/** One of password / privateKey is required. */
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
/** Passphrase for an encrypted private key. */
|
||||
passphrase?: string;
|
||||
remoteDir: string;
|
||||
/** Optional pinned host-key fingerprint (sha256 hex). When set, the
|
||||
* connection is rejected unless the server's key matches. */
|
||||
hostFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface S3DestConfig {
|
||||
endpoint: string;
|
||||
region?: string;
|
||||
bucket: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
/** Key prefix within the bucket (e.g. "crm-backups/"). */
|
||||
prefix?: string;
|
||||
/** Default true; set false only for pl-text local MinIO test endpoints. */
|
||||
useSSL?: boolean;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/** Sort backup bundle filenames newest-first (timestamp-in-name sorts lexically). */
|
||||
export function sortBundlesNewestFirst(names: string[]): string[] {
|
||||
return names.filter((n) => n.startsWith(BACKUP_NAME_PREFIX)).sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
297
src/lib/services/backup-export.service.ts
Normal file
297
src/lib/services/backup-export.service.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Full-bundle backup export (Phase 4a — docs/storage-migration-and-backup-plan.md).
|
||||
*
|
||||
* Today's `runBackup()` (backup.service.ts) dumps ONLY the database, buffers the
|
||||
* whole dump in memory, and writes it back to the SAME storage backend it would
|
||||
* lose if storage died. This module produces a *complete*, backend-agnostic
|
||||
* disaster-recovery bundle:
|
||||
*
|
||||
* bundle.tar
|
||||
* ├── db.dump (pg_dump --format=custom of the live DB)
|
||||
* ├── blobs/<storage_key> (every blob referenced by a DB row)
|
||||
* └── manifest.json (sha256 + size per object, for restore-side verify)
|
||||
*
|
||||
* Streaming is mandatory: blobs are piped backend → tar with a known size
|
||||
* (`stats.size`, which is what makes archiver stream instead of buffering the
|
||||
* whole entry into memory) so total memory stays O(largest chunk), not
|
||||
* O(total bytes). The tar is assembled to a temp file first, then handed to the
|
||||
* caller to stream to the operator — so a mid-assembly failure surfaces as a
|
||||
* clean error rather than a truncated download.
|
||||
*
|
||||
* The assembler (`assembleBackupTar`) is pure w.r.t. its inputs (a storage
|
||||
* backend, a pre-produced dump file, a blob-ref list) so it is unit-tested with
|
||||
* an in-memory backend; the orchestrator (`createFullBackupTar`) wires in
|
||||
* pg_dump + the live blob inventory.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { stat, unlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Transform } from 'node:stream';
|
||||
|
||||
import archiver from 'archiver';
|
||||
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStorageBackend, type StorageBackend } from '@/lib/storage';
|
||||
import { collectStorageRefs } from '@/lib/storage/migrate';
|
||||
import { runPgDump } from '@/lib/services/backup.service';
|
||||
|
||||
/** A blob the bundle should attempt to include. */
|
||||
export interface BackupBlobRef {
|
||||
tableName: string;
|
||||
pk: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface BackupManifestBlobEntry {
|
||||
table: string;
|
||||
pk: string;
|
||||
key: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface BackupSkippedEntry {
|
||||
table: string;
|
||||
pk: string;
|
||||
key: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BackupManifest {
|
||||
formatVersion: number;
|
||||
createdAt: string;
|
||||
storageBackend: string;
|
||||
database: {
|
||||
file: string;
|
||||
format: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
};
|
||||
blobs: BackupManifestBlobEntry[];
|
||||
skipped: BackupSkippedEntry[];
|
||||
counts: {
|
||||
blobs: number;
|
||||
blobBytes: number;
|
||||
skipped: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe `source` into the archive under `name`, computing the sha256 and byte
|
||||
* count of exactly the bytes that pass through. Resolves once the entry has
|
||||
* been fully consumed by archiver.
|
||||
*
|
||||
* `stats.size` MUST be supplied: archiver's tar plugin streams the entry only
|
||||
* when `data.stats` is present (otherwise it buffers the whole stream into
|
||||
* memory via `collectStream` to discover the size — the exact behaviour we're
|
||||
* avoiding for multi-GB blob sets).
|
||||
*/
|
||||
function appendHashedStream(
|
||||
archive: archiver.Archiver,
|
||||
source: NodeJS.ReadableStream,
|
||||
name: string,
|
||||
declaredSize: number,
|
||||
now: Date,
|
||||
): Promise<{ sha256: string; bytes: number }> {
|
||||
const hash = createHash('sha256');
|
||||
let bytes = 0;
|
||||
const tee = new Transform({
|
||||
transform(chunk: Buffer, _enc, cb) {
|
||||
hash.update(chunk);
|
||||
bytes += chunk.length;
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const done = new Promise<{ sha256: string; bytes: number }>((resolve, reject) => {
|
||||
source.on('error', (err) => tee.destroy(err instanceof Error ? err : new Error(String(err))));
|
||||
tee.on('error', reject);
|
||||
tee.on('end', () => resolve({ sha256: hash.digest('hex'), bytes }));
|
||||
});
|
||||
|
||||
source.pipe(tee);
|
||||
archive.append(tee, {
|
||||
name,
|
||||
date: now,
|
||||
// A minimal fs.Stats-like object. `size` engages archiver's streaming
|
||||
// tar path; `mode`/`mtime` keep the header deterministic.
|
||||
stats: {
|
||||
size: declaredSize,
|
||||
mode: 0o644,
|
||||
mtime: now,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as unknown as import('node:fs').Stats,
|
||||
});
|
||||
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a backup tar at `outFilePath` from a pre-produced pg_dump file and a
|
||||
* list of blob references. Returns the manifest describing the bundle.
|
||||
*/
|
||||
export async function assembleBackupTar(opts: {
|
||||
backend: StorageBackend;
|
||||
dumpFilePath: string;
|
||||
blobRefs: BackupBlobRef[];
|
||||
outFilePath: string;
|
||||
storageBackendName: string;
|
||||
now: Date;
|
||||
}): Promise<BackupManifest> {
|
||||
const { backend, dumpFilePath, blobRefs, outFilePath, storageBackendName, now } = opts;
|
||||
|
||||
const archive = archiver('tar');
|
||||
const output = createWriteStream(outFilePath);
|
||||
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
output.on('close', () => resolve());
|
||||
output.on('error', reject);
|
||||
archive.on('error', reject);
|
||||
archive.on('warning', (err: Error & { code?: string }) => {
|
||||
// Non-fatal (e.g. ENOENT on a vanished file) — log and keep going.
|
||||
logger.warn({ err }, 'archiver warning during backup export');
|
||||
});
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
// 1. db.dump
|
||||
const dumpStat = await stat(dumpFilePath);
|
||||
const dump = await appendHashedStream(
|
||||
archive,
|
||||
createReadStream(dumpFilePath),
|
||||
'db.dump',
|
||||
dumpStat.size,
|
||||
now,
|
||||
);
|
||||
|
||||
// 2. blobs (one at a time so memory stays bounded)
|
||||
const blobs: BackupManifestBlobEntry[] = [];
|
||||
const skipped: BackupSkippedEntry[] = [];
|
||||
for (const ref of blobRefs) {
|
||||
const head = await backend.head(ref.key);
|
||||
if (!head) {
|
||||
skipped.push({
|
||||
table: ref.tableName,
|
||||
pk: ref.pk,
|
||||
key: ref.key,
|
||||
reason: 'missing-in-storage',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let source: NodeJS.ReadableStream;
|
||||
try {
|
||||
source = await backend.get(ref.key);
|
||||
} catch (err) {
|
||||
skipped.push({
|
||||
table: ref.tableName,
|
||||
pk: ref.pk,
|
||||
key: ref.key,
|
||||
reason: `unreadable: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const { sha256, bytes } = await appendHashedStream(
|
||||
archive,
|
||||
source,
|
||||
`blobs/${ref.key}`,
|
||||
head.sizeBytes,
|
||||
now,
|
||||
);
|
||||
blobs.push({ table: ref.tableName, pk: ref.pk, key: ref.key, sizeBytes: bytes, sha256 });
|
||||
}
|
||||
|
||||
// 3. manifest.json (last — it carries the sha256 of every prior entry)
|
||||
const manifest: BackupManifest = {
|
||||
formatVersion: 1,
|
||||
createdAt: now.toISOString(),
|
||||
storageBackend: storageBackendName,
|
||||
database: {
|
||||
file: 'db.dump',
|
||||
format: 'pg_dump-custom',
|
||||
sizeBytes: dump.bytes,
|
||||
sha256: dump.sha256,
|
||||
},
|
||||
blobs,
|
||||
skipped,
|
||||
counts: {
|
||||
blobs: blobs.length,
|
||||
blobBytes: blobs.reduce((acc, b) => acc + b.sizeBytes, 0),
|
||||
skipped: skipped.length,
|
||||
},
|
||||
};
|
||||
archive.append(Buffer.from(JSON.stringify(manifest, null, 2)), {
|
||||
name: 'manifest.json',
|
||||
date: now,
|
||||
});
|
||||
|
||||
await archive.finalize();
|
||||
await finished;
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export interface FullBackupResult {
|
||||
tarPath: string;
|
||||
filename: string;
|
||||
manifest: BackupManifest;
|
||||
/** Removes the assembled tar. The intermediate dump is removed eagerly. */
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrate a full backup: pg_dump the live DB, inventory every blob, and
|
||||
* assemble the bundle to a temp tar. The caller streams `tarPath` to the
|
||||
* operator and invokes `cleanup()` when the download finishes.
|
||||
*
|
||||
* `backup_jobs` blobs (prior pg_dump artefacts) are excluded so a full export
|
||||
* doesn't recursively bundle previous backups.
|
||||
*/
|
||||
export async function createFullBackupTar(): Promise<FullBackupResult> {
|
||||
const now = new Date();
|
||||
const id = crypto.randomUUID();
|
||||
const dumpPath = path.join(tmpdir(), `pn-fullbackup-${id}.dump`);
|
||||
const tarPath = path.join(tmpdir(), `pn-fullbackup-${id}.tar`);
|
||||
|
||||
try {
|
||||
await runPgDump(env.DATABASE_URL, dumpPath);
|
||||
const backend = await getStorageBackend();
|
||||
const refs = await collectStorageRefs({ excludeTables: ['backup_jobs'] });
|
||||
const blobRefs: BackupBlobRef[] = refs.map((r) => ({
|
||||
tableName: r.tableName,
|
||||
pk: r.pk,
|
||||
key: r.key,
|
||||
}));
|
||||
|
||||
const manifest = await assembleBackupTar({
|
||||
backend,
|
||||
dumpFilePath: dumpPath,
|
||||
blobRefs,
|
||||
outFilePath: tarPath,
|
||||
storageBackendName: backend.name,
|
||||
now,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ blobs: manifest.counts.blobs, skipped: manifest.counts.skipped },
|
||||
'Full backup bundle assembled',
|
||||
);
|
||||
|
||||
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
||||
return {
|
||||
tarPath,
|
||||
filename: `pn-crm-backup-${stamp}.tar`,
|
||||
manifest,
|
||||
cleanup: async () => {
|
||||
await unlink(tarPath).catch(() => {});
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
// The dump is already inside the tar (or assembly failed) — drop it now.
|
||||
await unlink(dumpPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -88,24 +88,63 @@ export async function runBackup({ trigger, triggeredBy }: RunBackupArgs): Promis
|
||||
}
|
||||
}
|
||||
|
||||
function runPgDump(databaseUrl: string, outFile: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('pg_dump', ['--format=custom', '--no-owner', databaseUrl]);
|
||||
const out = createWriteStream(outFile);
|
||||
child.stdout.pipe(out);
|
||||
export interface RunPgDumpOpts {
|
||||
/** Override the binary (tests inject `node`). Defaults to `pg_dump`. */
|
||||
command?: string;
|
||||
/** Build the argv from the connection URL. Defaults to a custom-format dump. */
|
||||
buildArgs?: (databaseUrl: string) => string[];
|
||||
}
|
||||
|
||||
export function runPgDump(
|
||||
databaseUrl: string,
|
||||
outFile: string,
|
||||
opts: RunPgDumpOpts = {},
|
||||
): Promise<void> {
|
||||
const command = opts.command ?? 'pg_dump';
|
||||
const args = (opts.buildArgs ?? ((url) => ['--format=custom', '--no-owner', url]))(databaseUrl);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args);
|
||||
const out = createWriteStream(outFile);
|
||||
// `stdout.pipe(out)` auto-ends `out` when the child's stdout closes, so the
|
||||
// file's `finish` event can fire *before* the process `close` event. Gate
|
||||
// resolution on BOTH having happened rather than attaching the `finish`
|
||||
// listener inside the `close` handler (which raced and hung when `finish`
|
||||
// had already fired).
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
let exitCode: number | null = null;
|
||||
let fileFlushed = false;
|
||||
|
||||
const fail = (err: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
};
|
||||
const maybeResolve = (): void => {
|
||||
if (settled || exitCode === null || !fileFlushed) return;
|
||||
if (exitCode === 0) {
|
||||
settled = true;
|
||||
resolve();
|
||||
} else {
|
||||
fail(new Error(`pg_dump exited ${exitCode}: ${stderr}`));
|
||||
}
|
||||
};
|
||||
|
||||
child.stderr.on('data', (b) => {
|
||||
stderr += b.toString();
|
||||
});
|
||||
child.on('error', (err) => reject(err));
|
||||
child.on('close', (code) => {
|
||||
out.end();
|
||||
child.on('error', fail);
|
||||
out.on('error', fail);
|
||||
out.on('finish', () => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`pg_dump exited ${code}: ${stderr}`));
|
||||
fileFlushed = true;
|
||||
maybeResolve();
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
exitCode = code;
|
||||
maybeResolve();
|
||||
});
|
||||
child.stdout.pipe(out);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ async function markRowMigrated(
|
||||
`);
|
||||
}
|
||||
|
||||
interface RowRef {
|
||||
export interface RowRef {
|
||||
tableName: string;
|
||||
pk: string;
|
||||
key: string;
|
||||
@@ -164,6 +164,22 @@ async function listKeysFor(tbl: StorageKeyTable): Promise<RowRef[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory every blob reference across all blob-bearing tables. Used by the
|
||||
* full-backup exporter (Phase 4a) to enumerate what to bundle. `excludeTables`
|
||||
* lets the exporter drop `backup_jobs` so a full export doesn't recursively
|
||||
* include prior backup artefacts.
|
||||
*/
|
||||
export async function collectStorageRefs(opts?: { excludeTables?: string[] }): Promise<RowRef[]> {
|
||||
const exclude = new Set(opts?.excludeTables ?? []);
|
||||
const all: RowRef[] = [];
|
||||
for (const tbl of TABLES_WITH_STORAGE_KEYS) {
|
||||
if (exclude.has(tbl.table)) continue;
|
||||
all.push(...(await listKeysFor(tbl)));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// ─── streaming + sha256 verify ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
66
src/lib/validators/backup-destinations.ts
Normal file
66
src/lib/validators/backup-destinations.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Per-type connection config. Secret fields are optional so an edit can leave
|
||||
* them blank to keep the stored (encrypted) value; create-time omission is
|
||||
* surfaced by the destination's "Test connection" instead. */
|
||||
const filesystemConfigSchema = z.object({
|
||||
directory: z.string().min(1, 'Directory is required'),
|
||||
});
|
||||
|
||||
const sftpConfigSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.number().int().positive().max(65535).optional(),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().optional(),
|
||||
privateKey: z.string().optional(),
|
||||
passphrase: z.string().optional(),
|
||||
remoteDir: z.string().min(1, 'Remote directory is required'),
|
||||
hostFingerprint: z.string().optional(),
|
||||
});
|
||||
|
||||
const s3ConfigSchema = z.object({
|
||||
endpoint: z.string().min(1, 'Endpoint is required'),
|
||||
region: z.string().optional(),
|
||||
bucket: z.string().min(1, 'Bucket is required'),
|
||||
accessKey: z.string().min(1, 'Access key is required'),
|
||||
secretKey: z.string().optional(),
|
||||
prefix: z.string().optional(),
|
||||
useSSL: z.boolean().optional(),
|
||||
port: z.number().int().positive().max(65535).optional(),
|
||||
});
|
||||
|
||||
const CONFIG_SCHEMA_BY_TYPE = {
|
||||
filesystem: filesystemConfigSchema,
|
||||
sftp: sftpConfigSchema,
|
||||
s3: s3ConfigSchema,
|
||||
} as const;
|
||||
|
||||
export const backupDestinationSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
type: z.enum(['sftp', 's3', 'filesystem']),
|
||||
enabled: z.boolean().optional(),
|
||||
config: z.record(z.string(), z.unknown()),
|
||||
retentionCount: z.number().int().min(0).max(10000).nullable().optional(),
|
||||
encryptBundle: z.boolean().optional(),
|
||||
encryptionKey: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
const schema = CONFIG_SCHEMA_BY_TYPE[val.type];
|
||||
const result = schema.safeParse(val.config);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
ctx.addIssue({ ...issue, path: ['config', ...issue.path] });
|
||||
}
|
||||
}
|
||||
if (val.encryptBundle && !val.encryptionKey) {
|
||||
// Allowed on update (keeps existing key); the route/service enforces a key
|
||||
// exists before a push. Surfaced here only as a soft hint via no error.
|
||||
}
|
||||
});
|
||||
|
||||
export const backupScheduleSchema = z.object({
|
||||
schedule: z.enum(['off', 'daily', 'weekly']),
|
||||
});
|
||||
|
||||
export type BackupDestinationInput = z.infer<typeof backupDestinationSchema>;
|
||||
75
tests/unit/services/backup-bundle-encryption.test.ts
Normal file
75
tests/unit/services/backup-bundle-encryption.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Unit test for opt-in backup-bundle encryption
|
||||
* (`src/lib/services/backup-destinations/bundle-encryption.ts`).
|
||||
*
|
||||
* Contract: AES-256-GCM streaming encryption with a scrypt-derived key.
|
||||
* - round-trips arbitrary bytes (small + multi-chunk),
|
||||
* - rejects the wrong passphrase (GCM auth failure),
|
||||
* - rejects tampered ciphertext (GCM auth failure).
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decryptFileToFile,
|
||||
encryptFileToFile,
|
||||
} from '@/lib/services/backup-destinations/bundle-encryption';
|
||||
|
||||
function sha(p: string): string {
|
||||
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
}
|
||||
|
||||
describe('backup bundle encryption', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'pn-enc-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips a multi-chunk file with the correct passphrase', async () => {
|
||||
const plain = path.join(dir, 'bundle.tar');
|
||||
// ~400 KB of pseudo-random bytes → multiple cipher chunks.
|
||||
writeFileSync(plain, randomBytes(400 * 1024));
|
||||
const enc = path.join(dir, 'bundle.tar.enc');
|
||||
const dec = path.join(dir, 'bundle.roundtrip.tar');
|
||||
|
||||
await encryptFileToFile(plain, enc, 'correct horse battery staple');
|
||||
// Ciphertext must differ from plaintext and not be empty.
|
||||
expect(statSync(enc).size).toBeGreaterThan(0);
|
||||
expect(sha(enc)).not.toBe(sha(plain));
|
||||
|
||||
await decryptFileToFile(enc, dec, 'correct horse battery staple');
|
||||
expect(sha(dec)).toBe(sha(plain));
|
||||
});
|
||||
|
||||
it('rejects the wrong passphrase', async () => {
|
||||
const plain = path.join(dir, 'b.tar');
|
||||
writeFileSync(plain, Buffer.from('top secret contract bytes'));
|
||||
const enc = path.join(dir, 'b.tar.enc');
|
||||
await encryptFileToFile(plain, enc, 'right-pass');
|
||||
|
||||
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'WRONG-pass')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects tampered ciphertext', async () => {
|
||||
const plain = path.join(dir, 'c.tar');
|
||||
writeFileSync(plain, randomBytes(64 * 1024));
|
||||
const enc = path.join(dir, 'c.tar.enc');
|
||||
await encryptFileToFile(plain, enc, 'pw');
|
||||
|
||||
// Flip a byte in the middle of the ciphertext region.
|
||||
const buf = readFileSync(enc);
|
||||
const mid = Math.floor(buf.length / 2);
|
||||
buf[mid] = (buf[mid] ?? 0) ^ 0xff;
|
||||
writeFileSync(enc, buf);
|
||||
|
||||
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'pw')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
86
tests/unit/services/backup-destinations-service.test.ts
Normal file
86
tests/unit/services/backup-destinations-service.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers of the backup-destinations service:
|
||||
* schedule-due logic + secret config (serialize → encrypt at rest →
|
||||
* decrypt for use, mask for API). DB-backed CRUD is covered by the e2e
|
||||
* verification, not here.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decryptConfig,
|
||||
isScheduleDue,
|
||||
maskConfig,
|
||||
serializeConfig,
|
||||
} from '@/lib/services/backup-destinations.service';
|
||||
|
||||
describe('isScheduleDue', () => {
|
||||
// 2026-06-07 is a Sunday; 2026-06-08 a Monday.
|
||||
const sunday = new Date('2026-06-07T02:00:00Z');
|
||||
const monday = new Date('2026-06-08T02:00:00Z');
|
||||
|
||||
it('off is never due', () => {
|
||||
expect(isScheduleDue('off', sunday)).toBe(false);
|
||||
expect(isScheduleDue('off', monday)).toBe(false);
|
||||
});
|
||||
it('daily is always due', () => {
|
||||
expect(isScheduleDue('daily', sunday)).toBe(true);
|
||||
expect(isScheduleDue('daily', monday)).toBe(true);
|
||||
});
|
||||
it('weekly is due only on Sunday', () => {
|
||||
expect(isScheduleDue('weekly', sunday)).toBe(true);
|
||||
expect(isScheduleDue('weekly', monday)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret config handling', () => {
|
||||
it('serialize encrypts secrets, decrypt restores them, mask hides them', () => {
|
||||
const incoming = {
|
||||
host: 'box.example.com',
|
||||
username: 'crm',
|
||||
password: 'hunter2',
|
||||
remoteDir: '/backups',
|
||||
};
|
||||
const stored = serializeConfig('sftp', incoming);
|
||||
|
||||
// Stored password must not be the plaintext.
|
||||
expect(stored.password).not.toBe('hunter2');
|
||||
expect(stored.host).toBe('box.example.com');
|
||||
|
||||
// Decrypt restores the plaintext for transport use.
|
||||
expect(decryptConfig('sftp', stored)).toMatchObject({
|
||||
host: 'box.example.com',
|
||||
username: 'crm',
|
||||
password: 'hunter2',
|
||||
remoteDir: '/backups',
|
||||
});
|
||||
|
||||
// Mask hides the secret and exposes only a *IsSet marker.
|
||||
const masked = maskConfig('sftp', stored);
|
||||
expect(masked.password).toBeUndefined();
|
||||
expect(masked.passwordIsSet).toBe(true);
|
||||
expect(masked.host).toBe('box.example.com');
|
||||
});
|
||||
|
||||
it('update with a blank secret keeps the existing encrypted value', () => {
|
||||
const original = serializeConfig('s3', {
|
||||
endpoint: 'https://s3.example.com',
|
||||
bucket: 'b',
|
||||
accessKey: 'AK',
|
||||
secretKey: 'SUPERSECRET',
|
||||
});
|
||||
// Admin edits the bucket but leaves the secret key blank (unchanged).
|
||||
const updated = serializeConfig(
|
||||
's3',
|
||||
{ endpoint: 'https://s3.example.com', bucket: 'b2', accessKey: 'AK', secretKey: '' },
|
||||
original,
|
||||
);
|
||||
expect(updated.bucket).toBe('b2');
|
||||
expect(decryptConfig('s3', updated).secretKey).toBe('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('filesystem has no secrets to mask', () => {
|
||||
const stored = serializeConfig('filesystem', { directory: '/mnt/nas/backups' });
|
||||
expect(maskConfig('filesystem', stored)).toEqual({ directory: '/mnt/nas/backups' });
|
||||
});
|
||||
});
|
||||
72
tests/unit/services/backup-filesystem-transport.test.ts
Normal file
72
tests/unit/services/backup-filesystem-transport.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Unit test for the filesystem (mounted-path / NAS) backup transport
|
||||
* (`src/lib/services/backup-destinations/filesystem.ts`).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { FilesystemTransport } from '@/lib/services/backup-destinations/filesystem';
|
||||
import { BACKUP_NAME_PREFIX } from '@/lib/services/backup-destinations/types';
|
||||
|
||||
describe('FilesystemTransport', () => {
|
||||
let work: string;
|
||||
let destDir: string;
|
||||
beforeEach(() => {
|
||||
work = mkdtempSync(path.join(tmpdir(), 'pn-fstr-'));
|
||||
destDir = path.join(work, 'backups');
|
||||
mkdirSync(destDir);
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test() rejects when the directory does not exist', async () => {
|
||||
const t = new FilesystemTransport({ directory: path.join(work, 'nope') });
|
||||
await expect(t.test()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('push() copies the bundle into the destination directory', async () => {
|
||||
const local = path.join(work, `${BACKUP_NAME_PREFIX}2026-06-04.tar`);
|
||||
writeFileSync(local, Buffer.from('BUNDLE-BYTES'));
|
||||
const t = new FilesystemTransport({ directory: destDir });
|
||||
|
||||
const res = await t.push(local, `${BACKUP_NAME_PREFIX}2026-06-04.tar`);
|
||||
|
||||
expect(res.bytes).toBe('BUNDLE-BYTES'.length);
|
||||
const landed = readFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-04.tar`), 'utf8');
|
||||
expect(landed).toBe('BUNDLE-BYTES');
|
||||
});
|
||||
|
||||
it('prune() keeps the N newest bundles and ignores unrelated files', async () => {
|
||||
// Five backups (timestamp-in-name sorts chronologically) + an unrelated file.
|
||||
for (const d of ['01', '02', '03', '04', '05']) {
|
||||
writeFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-${d}.tar`), 'x');
|
||||
}
|
||||
writeFileSync(path.join(destDir, 'unrelated-keepme.txt'), 'y');
|
||||
|
||||
const t = new FilesystemTransport({ directory: destDir });
|
||||
const { deleted } = await t.prune(2);
|
||||
|
||||
expect(deleted).toBe(3);
|
||||
const remaining = readdirSync(destDir).sort();
|
||||
expect(remaining).toEqual([
|
||||
`${BACKUP_NAME_PREFIX}2026-06-04.tar`,
|
||||
`${BACKUP_NAME_PREFIX}2026-06-05.tar`,
|
||||
'unrelated-keepme.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prune(null) keeps everything', async () => {
|
||||
for (const d of ['01', '02', '03']) {
|
||||
writeFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-${d}.tar`), 'x');
|
||||
}
|
||||
const t = new FilesystemTransport({ directory: destDir });
|
||||
const { deleted } = await t.prune(null);
|
||||
expect(deleted).toBe(0);
|
||||
expect(readdirSync(destDir).length).toBe(3);
|
||||
});
|
||||
});
|
||||
63
tests/unit/services/backup-transport-factory.test.ts
Normal file
63
tests/unit/services/backup-transport-factory.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Unit test for the transport factory + S3 endpoint parser
|
||||
* (`src/lib/services/backup-destinations/`).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTransport,
|
||||
FilesystemTransport,
|
||||
parseS3Endpoint,
|
||||
S3Transport,
|
||||
SftpTransport,
|
||||
} from '@/lib/services/backup-destinations';
|
||||
|
||||
describe('buildTransport', () => {
|
||||
it('builds the right transport per type', () => {
|
||||
expect(buildTransport('filesystem', { directory: '/x' })).toBeInstanceOf(FilesystemTransport);
|
||||
expect(buildTransport('sftp', { host: 'h', username: 'u', remoteDir: '/d' })).toBeInstanceOf(
|
||||
SftpTransport,
|
||||
);
|
||||
expect(
|
||||
buildTransport('s3', { endpoint: 'h', bucket: 'b', accessKey: 'a', secretKey: 's' }),
|
||||
).toBeInstanceOf(S3Transport);
|
||||
});
|
||||
|
||||
it('throws on an unknown type', () => {
|
||||
// @ts-expect-error testing the runtime guard
|
||||
expect(() => buildTransport('ftp', {})).toThrow(/Unknown backup destination type/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseS3Endpoint', () => {
|
||||
it('parses an https URL into host + ssl', () => {
|
||||
expect(parseS3Endpoint('https://s3.eu-central.example.com', {})).toEqual({
|
||||
endPoint: 's3.eu-central.example.com',
|
||||
useSSL: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an http URL with a port', () => {
|
||||
expect(parseS3Endpoint('http://localhost:9000', {})).toEqual({
|
||||
endPoint: 'localhost',
|
||||
port: 9000,
|
||||
useSSL: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a bare host as ssl-by-default', () => {
|
||||
expect(parseS3Endpoint('s3.amazonaws.com', {})).toEqual({
|
||||
endPoint: 's3.amazonaws.com',
|
||||
useSSL: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours explicit useSSL=false on a bare host', () => {
|
||||
expect(parseS3Endpoint('minio.internal', { useSSL: false, port: 9000 })).toEqual({
|
||||
endPoint: 'minio.internal',
|
||||
port: 9000,
|
||||
useSSL: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
63
tests/unit/services/pg-dump-runner.test.ts
Normal file
63
tests/unit/services/pg-dump-runner.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Unit test for `runPgDump` in backup.service.ts.
|
||||
*
|
||||
* Regression: the dump promise must resolve once BOTH (a) the child process
|
||||
* exits 0 and (b) the output file is fully flushed — regardless of which event
|
||||
* fires first. The original implementation attached the file's `finish`
|
||||
* listener *inside* the child `close` handler, but `stdout.pipe(out)`
|
||||
* auto-ends the file when the child's stdout closes, so `finish` frequently
|
||||
* fired before the listener was attached → the promise hung forever (observed
|
||||
* end-to-end against a real pg_dump).
|
||||
*
|
||||
* We drive the spawn with `node` instead of `pg_dump` (injected via opts) so
|
||||
* the test is deterministic and needs no database.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { runPgDump } from '@/lib/services/backup.service';
|
||||
|
||||
describe('runPgDump', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'pn-pgdump-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves and writes the child stdout to the output file on exit 0', async () => {
|
||||
const out = path.join(dir, 'a.dump');
|
||||
await runPgDump('ignored://url', out, {
|
||||
command: process.execPath,
|
||||
buildArgs: () => ['-e', 'process.stdout.write("DUMP-CONTENTS-1234")'],
|
||||
});
|
||||
expect(readFileSync(out, 'utf8')).toBe('DUMP-CONTENTS-1234');
|
||||
});
|
||||
|
||||
it('rejects with the captured stderr when the child exits non-zero', async () => {
|
||||
const out = path.join(dir, 'b.dump');
|
||||
await expect(
|
||||
runPgDump('ignored://url', out, {
|
||||
command: process.execPath,
|
||||
buildArgs: () => ['-e', 'process.stderr.write("kaboom"); process.exit(3)'],
|
||||
}),
|
||||
).rejects.toThrow(/exited 3.*kaboom/s);
|
||||
});
|
||||
|
||||
it('rejects when the command cannot be spawned', async () => {
|
||||
const out = path.join(dir, 'c.dump');
|
||||
await expect(
|
||||
runPgDump('ignored://url', out, {
|
||||
command: '/nonexistent/definitely-not-a-real-binary-xyz',
|
||||
buildArgs: () => [],
|
||||
}),
|
||||
).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
208
tests/unit/storage/backup-export.test.ts
Normal file
208
tests/unit/storage/backup-export.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Unit test for the full-bundle backup tar assembler
|
||||
* (`assembleBackupTar` in `src/lib/services/backup-export.service.ts`).
|
||||
*
|
||||
* Phase 4a of docs/storage-migration-and-backup-plan.md: assemble a single
|
||||
* tar containing `db.dump` (a pre-produced pg_dump file) + `blobs/<key>` for
|
||||
* every blob-bearing row + a `manifest.json` describing the bundle with a
|
||||
* sha256 per object so a restore can verify integrity.
|
||||
*
|
||||
* Uses an in-memory storage backend (no MinIO) and a synthetic dump file
|
||||
* (no pg_dump). The produced tar is read back with the system `tar` CLI so we
|
||||
* assert against the real archive bytes, not archiver internals.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { assembleBackupTar } from '@/lib/services/backup-export.service';
|
||||
import type { PresignOpts, PutOpts, StorageBackend } from '@/lib/storage';
|
||||
|
||||
class InMemoryBackend implements StorageBackend {
|
||||
readonly name = 's3' as const;
|
||||
readonly store = new Map<string, { body: Buffer; contentType: string }>();
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: Buffer | NodeJS.ReadableStream,
|
||||
opts: PutOpts,
|
||||
): Promise<{ key: string; sizeBytes: number; sha256: string }> {
|
||||
const buffer = Buffer.isBuffer(body) ? body : await streamToBuffer(body);
|
||||
this.store.set(key, { body: buffer, contentType: opts.contentType });
|
||||
return {
|
||||
key,
|
||||
sizeBytes: buffer.length,
|
||||
sha256: createHash('sha256').update(buffer).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
async get(key: string): Promise<NodeJS.ReadableStream> {
|
||||
const r = this.store.get(key);
|
||||
if (!r) throw new Error(`not found: ${key}`);
|
||||
// Chunk the body so multi-chunk streaming is exercised.
|
||||
const chunks: Buffer[] = [];
|
||||
for (let i = 0; i < r.body.length; i += 64 * 1024) {
|
||||
chunks.push(r.body.subarray(i, i + 64 * 1024));
|
||||
}
|
||||
return Readable.from(chunks.length ? chunks : [Buffer.alloc(0)]);
|
||||
}
|
||||
|
||||
async head(key: string) {
|
||||
const r = this.store.get(key);
|
||||
if (!r) return null;
|
||||
return { sizeBytes: r.body.length, contentType: r.contentType };
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
async listByPrefix(prefix: string): Promise<string[]> {
|
||||
return [...this.store.keys()].filter((k) => k.startsWith(prefix));
|
||||
}
|
||||
|
||||
async presignUpload(_key: string, _opts: PresignOpts) {
|
||||
return { url: 'mem://upload', method: 'PUT' as const };
|
||||
}
|
||||
|
||||
async presignDownload(_key: string, _opts: PresignOpts) {
|
||||
return { url: 'mem://download', expiresAt: new Date(Date.now() + 1000) };
|
||||
}
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function sha256(buf: Buffer): string {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
describe('assembleBackupTar', () => {
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(path.join(tmpdir(), 'pn-backup-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('bundles the db dump + every blob with a verifiable manifest', async () => {
|
||||
const backend = new InMemoryBackend();
|
||||
|
||||
// A small blob and a larger multi-chunk blob (exercises streaming).
|
||||
const smallBody = Buffer.from('the quick brown fox');
|
||||
const bigBody = Buffer.alloc(300 * 1024);
|
||||
for (let i = 0; i < bigBody.length; i++) bigBody[i] = (i * 7) % 256;
|
||||
await backend.put('port-nimara/files/a.bin', smallBody, { contentType: 'application/pdf' });
|
||||
await backend.put('port-nimara/berths/big.pdf', bigBody, { contentType: 'application/pdf' });
|
||||
|
||||
// Synthetic pg_dump file.
|
||||
const dumpBody = Buffer.from('PGDMP-FAKE-CUSTOM-FORMAT-CONTENTS\n'.repeat(1000));
|
||||
const dumpPath = path.join(workDir, 'db.dump');
|
||||
writeFileSync(dumpPath, dumpBody);
|
||||
|
||||
const outPath = path.join(workDir, 'bundle.tar');
|
||||
const now = new Date('2026-06-04T12:00:00.000Z');
|
||||
|
||||
const manifest = await assembleBackupTar({
|
||||
backend,
|
||||
dumpFilePath: dumpPath,
|
||||
blobRefs: [
|
||||
{ tableName: 'files', pk: 'f1', key: 'port-nimara/files/a.bin' },
|
||||
{ tableName: 'berth_pdf_versions', pk: 'b1', key: 'port-nimara/berths/big.pdf' },
|
||||
],
|
||||
outFilePath: outPath,
|
||||
storageBackendName: 's3',
|
||||
now,
|
||||
});
|
||||
|
||||
// ── manifest shape ──────────────────────────────────────────────────────
|
||||
expect(manifest.formatVersion).toBe(1);
|
||||
expect(manifest.createdAt).toBe('2026-06-04T12:00:00.000Z');
|
||||
expect(manifest.storageBackend).toBe('s3');
|
||||
expect(manifest.database.file).toBe('db.dump');
|
||||
expect(manifest.database.sizeBytes).toBe(dumpBody.length);
|
||||
expect(manifest.database.sha256).toBe(sha256(dumpBody));
|
||||
expect(manifest.counts.blobs).toBe(2);
|
||||
expect(manifest.counts.blobBytes).toBe(smallBody.length + bigBody.length);
|
||||
expect(manifest.counts.skipped).toBe(0);
|
||||
|
||||
const smallEntry = manifest.blobs.find((b) => b.key === 'port-nimara/files/a.bin');
|
||||
expect(smallEntry).toMatchObject({
|
||||
table: 'files',
|
||||
pk: 'f1',
|
||||
sizeBytes: smallBody.length,
|
||||
sha256: sha256(smallBody),
|
||||
});
|
||||
|
||||
// ── extract the real tar and verify bytes ────────────────────────────────
|
||||
const extractDir = path.join(workDir, 'extract');
|
||||
execFileSync('mkdir', ['-p', extractDir]);
|
||||
execFileSync('tar', ['-xf', outPath, '-C', extractDir]);
|
||||
|
||||
const extractedDump = readFileSync(path.join(extractDir, 'db.dump'));
|
||||
expect(extractedDump.equals(dumpBody)).toBe(true);
|
||||
|
||||
const extractedSmall = readFileSync(path.join(extractDir, 'blobs/port-nimara/files/a.bin'));
|
||||
expect(extractedSmall.equals(smallBody)).toBe(true);
|
||||
|
||||
const extractedBig = readFileSync(path.join(extractDir, 'blobs/port-nimara/berths/big.pdf'));
|
||||
expect(extractedBig.equals(bigBody)).toBe(true);
|
||||
|
||||
// The on-disk manifest matches the returned one and verifies the bytes.
|
||||
const onDiskManifest = JSON.parse(readFileSync(path.join(extractDir, 'manifest.json'), 'utf8'));
|
||||
expect(onDiskManifest).toEqual(manifest);
|
||||
for (const entry of manifest.blobs) {
|
||||
const bytes = readFileSync(path.join(extractDir, 'blobs', entry.key));
|
||||
expect(sha256(bytes)).toBe(entry.sha256);
|
||||
expect(bytes.length).toBe(entry.sizeBytes);
|
||||
}
|
||||
});
|
||||
|
||||
it('records referenced-but-missing blobs as skipped instead of failing', async () => {
|
||||
const backend = new InMemoryBackend();
|
||||
await backend.put('port-nimara/files/present.bin', Buffer.from('here'), {
|
||||
contentType: 'application/octet-stream',
|
||||
});
|
||||
|
||||
const dumpPath = path.join(workDir, 'db.dump');
|
||||
writeFileSync(dumpPath, Buffer.from('dump'));
|
||||
const outPath = path.join(workDir, 'bundle.tar');
|
||||
|
||||
const manifest = await assembleBackupTar({
|
||||
backend,
|
||||
dumpFilePath: dumpPath,
|
||||
blobRefs: [
|
||||
{ tableName: 'files', pk: 'f1', key: 'port-nimara/files/present.bin' },
|
||||
{ tableName: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' },
|
||||
],
|
||||
outFilePath: outPath,
|
||||
storageBackendName: 's3',
|
||||
now: new Date('2026-06-04T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(manifest.counts.blobs).toBe(1);
|
||||
expect(manifest.counts.skipped).toBe(1);
|
||||
expect(manifest.skipped).toEqual([
|
||||
expect.objectContaining({ table: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' }),
|
||||
]);
|
||||
|
||||
// The missing blob must NOT appear in the archive.
|
||||
const extractDir = path.join(workDir, 'extract');
|
||||
execFileSync('mkdir', ['-p', extractDir]);
|
||||
execFileSync('tar', ['-xf', outPath, '-C', extractDir]);
|
||||
const blobFiles = readdirSync(path.join(extractDir, 'blobs', 'port-nimara', 'files'));
|
||||
expect(blobFiles).toEqual(['present.bin']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user