mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
feat(recovery): add safe-mode recovery surface and emergency CLI (#1286)
* feat(recovery): add safe-mode recovery surface and emergency CLI Add a read-only Recovery tab under Settings (admin-only) backed by a new GET /api/diagnostics endpoint reporting app version, database integrity, encryption-key status, Docker reachability, account and SSO counts, and non-secret configuration. The endpoint loads without Docker or live metrics so it stays available when the dashboard does not, requires a genuine admin session, and builds its config block from a non-secret allowlist so no credentials are ever exposed. Expand the emergency command-line toolkit beyond the two-factor reset with seven host-level commands: reset-password, create-emergency-admin, clear-sessions, disable-sso, diagnostics, validate-db, and backup-data. Each prints its result, exits with a meaningful status code, and writes an audit entry where it changes state. Document the toolkit in a new operator guide and link it from the recovery and two-factor pages. * feat(recovery): download the emergency command reference as a text file The recovery commands are needed exactly when the dashboard is unreachable, so reading them only in-app is a chicken-and-egg problem. Add a Download button to the command-line section that saves the full `docker compose exec sencho ...` reference as a text file, letting operators keep it on hand before they need it. Reuses a shared download helper with the existing diagnostics export. * fix(recovery): harden diagnostics, backup, and emergency-admin against edge cases Address findings from an independent review of the recovery toolkit: - DiagnosticsService now degrades instead of throwing when a queried table is missing or corrupt: each read falls back and is folded into database.ok, so a broken database reports "problem detected" rather than failing the whole endpoint or showing a misleading healthy state with zeroed counts. - backup-data refuses a destination that resolves to the live database, which would otherwise report success while producing no separate copy. - create-emergency-admin now applies the same username rule as the user- management route, extracted to a shared helper so both stay in sync. Adds tests for a missing read table, a malformed emergency-admin username, and the backup same-target rejection.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Emergency CLI: back up the Sencho data directory (sencho.db + encryption.key)
|
||||
* to a target directory. Uses SQLite's online backup so the copy is consistent
|
||||
* even while Sencho is running.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/backupData.js [destination-dir]
|
||||
*
|
||||
* With no argument it writes a timestamped folder under <DATA_DIR>/backups.
|
||||
* Written to the audit log with actor `cli`.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
function dataDir(): string {
|
||||
return process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
}
|
||||
|
||||
export async function backupData(destArg?: string): Promise<CliResult> {
|
||||
const src = dataDir();
|
||||
const keyPath = path.join(src, 'encryption.key');
|
||||
const trimmedDest = destArg?.trim();
|
||||
const dest = trimmedDest
|
||||
? path.resolve(trimmedDest)
|
||||
: path.join(src, 'backups', `sencho-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
// Refuse a destination that would write the copy onto the live database
|
||||
// itself, which would report success while producing no separate backup.
|
||||
if (path.resolve(dest, 'sencho.db') === path.resolve(db.getDb().name)) {
|
||||
return { ok: false, message: 'Destination would overwrite the live database. Choose a different directory.' };
|
||||
}
|
||||
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
// Online backup produces a consistent snapshot even while the DB is in use.
|
||||
await db.getDb().backup(path.join(dest, 'sencho.db'));
|
||||
|
||||
let keyNote = '';
|
||||
if (fs.existsSync(keyPath)) {
|
||||
fs.copyFileSync(keyPath, path.join(dest, 'encryption.key'));
|
||||
keyNote = ' + encryption.key';
|
||||
}
|
||||
|
||||
auditCli(db, '/cli/backup-data', `CLI backed up data directory to ${dest}`);
|
||||
return { ok: true, message: `Backup written to ${dest} (sencho.db${keyNote}). Store it somewhere safe; it contains your encryption key.` };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
exitWith(await backupData(process.argv[2]));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
Reference in New Issue
Block a user