mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +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,56 @@
|
||||
/**
|
||||
* Shared helpers for the emergency recovery CLI commands in this directory.
|
||||
* Each command exports a testable function and a thin `main()` wrapper; these
|
||||
* helpers carry the audit-write and exit-code conventions so the individual
|
||||
* commands stay focused on their own logic.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
export interface CliResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a CLI action in the audit log with the conventional `cli` actor, then
|
||||
* flush immediately. The buffer's 1s flush timer never fires because the CLI
|
||||
* process exits first, so the explicit flush is what persists the entry. An
|
||||
* audit-write failure is logged but never aborts the action it was recording.
|
||||
*/
|
||||
export function auditCli(db: DatabaseService, path: string, summary: string): void {
|
||||
try {
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: 'cli',
|
||||
method: 'POST',
|
||||
path,
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: 'cli',
|
||||
summary,
|
||||
});
|
||||
// Flush inside the try: the buffer's 1s timer never fires before the
|
||||
// CLI exits, so this is what persists the entry. Keeping it under the
|
||||
// same catch means a broken DB handle cannot throw a second, uncaught
|
||||
// error after the recovery action it records has already succeeded.
|
||||
db.flushAuditLogBuffer();
|
||||
} catch (err) {
|
||||
console.warn(`[cli] audit log write failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Print the result and exit 0 (ok) or 1 (error), matching resetMfa.ts. */
|
||||
export function exitWith(result: CliResult): never {
|
||||
if (result.ok) {
|
||||
console.log(result.message);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(result.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Print a usage error to stderr and exit 2 (matches resetMfa.ts). */
|
||||
export function usage(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Emergency CLI: invalidate every active session by bumping every user's
|
||||
* token_version. Used after a suspected cookie theft or when a wedged login
|
||||
* state needs a clean sign-out of every user on this node.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/clearSessions.js
|
||||
*
|
||||
* Written to the audit log with actor `cli`.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
export function clearSessions(): CliResult {
|
||||
const db = DatabaseService.getInstance();
|
||||
const count = db.bumpAllTokenVersions();
|
||||
auditCli(db, '/cli/clear-sessions', `CLI cleared all sessions (${count} users)`);
|
||||
return { ok: true, message: `Cleared sessions for ${count} user(s). Everyone must sign in again.` };
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
exitWith(clearSessions());
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Emergency CLI: create a fresh local admin account from a shell inside the
|
||||
* container, used when every admin is locked out but the database is otherwise
|
||||
* intact (so resetting first-boot setup would needlessly discard config).
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/createEmergencyAdmin.js <username> <password>
|
||||
*
|
||||
* Refuses to overwrite an existing user; use resetPassword for that. Written to
|
||||
* the audit log with actor `cli`.
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { BCRYPT_SALT_ROUNDS } from '../helpers/constants';
|
||||
import { validateUsername } from '../helpers/validateUsername';
|
||||
import { auditCli, exitWith, usage, type CliResult } from './_shared';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export async function createEmergencyAdmin(username: string, password: string): Promise<CliResult> {
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) {
|
||||
return { ok: false, message: usernameError };
|
||||
}
|
||||
if (!password || password.length < MIN_PASSWORD_LENGTH) {
|
||||
return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` };
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getUserByUsername(username)) {
|
||||
return { ok: false, message: `User already exists: ${username}. Use reset-password instead.` };
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
db.addUser({ username, password_hash: passwordHash, role: 'admin', auth_provider: 'local' });
|
||||
auditCli(db, `/cli/create-emergency-admin/${username}`, `CLI created emergency admin ${username}`);
|
||||
return { ok: true, message: `Emergency admin ${username} created. Sign in and review your other accounts.` };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const username = process.argv[2];
|
||||
const password = process.argv[3];
|
||||
if (!username || !password) {
|
||||
usage('Usage: node dist/cli/createEmergencyAdmin.js <username> <password>');
|
||||
}
|
||||
exitWith(await createEmergencyAdmin(username, password));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Emergency CLI: print a redacted diagnostic summary (version, database
|
||||
* integrity, encryption-key status, admin/SSO/MFA counts, non-secret config) as
|
||||
* JSON. Read-only; carries no secrets (see DiagnosticsService for the
|
||||
* redaction allowlist). Safe to copy into a bug report.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/diagnostics.js
|
||||
*/
|
||||
import { collectDiagnostics } from '../services/DiagnosticsService';
|
||||
|
||||
export async function printDiagnostics(): Promise<void> {
|
||||
const report = await collectDiagnostics();
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await printDiagnostics();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(`Failed to collect diagnostics: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Emergency CLI: disable a broken SSO/OIDC/LDAP provider so local password
|
||||
* sign-in is reachable again. Used when a misconfigured identity provider
|
||||
* blocks the login screen.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/disableSso.js [provider]
|
||||
*
|
||||
* With no argument it disables every enabled provider. The stored configuration
|
||||
* is preserved (only the enabled flag is cleared) so it can be fixed and
|
||||
* re-enabled from the UI. Written to the audit log with actor `cli`.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
export function disableSso(provider?: string): CliResult {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
if (provider) {
|
||||
const config = db.getSSOConfig(provider);
|
||||
if (!config) {
|
||||
return { ok: false, message: `No SSO config found for provider: ${provider}` };
|
||||
}
|
||||
if (config.enabled !== 1) {
|
||||
return { ok: true, message: `SSO provider ${provider} is already disabled.` };
|
||||
}
|
||||
db.upsertSSOConfig(provider, false, config.config_json);
|
||||
auditCli(db, `/cli/disable-sso/${provider}`, `CLI disabled SSO provider ${provider}`);
|
||||
return { ok: true, message: `Disabled SSO provider ${provider}. Its configuration was preserved.` };
|
||||
}
|
||||
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
if (enabled.length === 0) {
|
||||
return { ok: true, message: 'No SSO providers are currently enabled.' };
|
||||
}
|
||||
for (const config of enabled) {
|
||||
db.upsertSSOConfig(config.provider, false, config.config_json);
|
||||
}
|
||||
const names = enabled.map(c => c.provider).join(', ');
|
||||
auditCli(db, '/cli/disable-sso', `CLI disabled all SSO providers (${enabled.length})`);
|
||||
return { ok: true, message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.` };
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
exitWith(disableSso(process.argv[2]));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Emergency CLI: reset a local user's password from a shell inside the
|
||||
* container, used when the admin password is forgotten and no other admin can
|
||||
* sign in to reset it from the UI.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/resetPassword.js <username> <new-password>
|
||||
*
|
||||
* Existing sessions for the target are invalidated by bumping `token_version`,
|
||||
* and the reset is written to the audit log with actor `cli`.
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { BCRYPT_SALT_ROUNDS } from '../helpers/constants';
|
||||
import { auditCli, exitWith, usage, type CliResult } from './_shared';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export async function resetPassword(username: string, newPassword: string): Promise<CliResult> {
|
||||
if (!username || typeof username !== 'string') {
|
||||
return { ok: false, message: 'Username is required' };
|
||||
}
|
||||
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` };
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
if (!user) {
|
||||
return { ok: false, message: `User not found: ${username}` };
|
||||
}
|
||||
if (user.auth_provider !== 'local') {
|
||||
return { ok: false, message: `User ${username} signs in via ${user.auth_provider}; password reset applies to local accounts only` };
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
|
||||
db.updateUser(user.id, { password_hash: passwordHash });
|
||||
db.bumpTokenVersion(user.id);
|
||||
auditCli(db, `/cli/reset-password/${username}`, `CLI reset password for ${username}`);
|
||||
return { ok: true, message: `Password reset for ${username}. Existing sessions were signed out.` };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const username = process.argv[2];
|
||||
const newPassword = process.argv[3];
|
||||
if (!username || !newPassword) {
|
||||
usage('Usage: node dist/cli/resetPassword.js <username> <new-password>');
|
||||
}
|
||||
exitWith(await resetPassword(username, newPassword));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Emergency CLI: validate that the database and encryption key are intact.
|
||||
* Runs a SQLite integrity check, confirms the core tables exist, confirms the
|
||||
* encryption key is present and usable, and confirms at least one admin exists.
|
||||
* Exits non-zero if any check fails, so it can gate a restore decision in a
|
||||
* script. Read-only.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/validateDb.js
|
||||
*/
|
||||
import { collectDiagnostics } from '../services/DiagnosticsService';
|
||||
import { exitWith, type CliResult } from './_shared';
|
||||
|
||||
export async function validateDb(): Promise<CliResult> {
|
||||
const report = await collectDiagnostics();
|
||||
const problems: string[] = [];
|
||||
|
||||
if (!report.database.ok) {
|
||||
problems.push(`database integrity: ${report.database.integrity}`);
|
||||
if (report.database.missingTables.length > 0) {
|
||||
problems.push(`missing core tables: ${report.database.missingTables.join(', ')}`);
|
||||
}
|
||||
}
|
||||
if (!report.encryptionKey.present) {
|
||||
problems.push('encryption.key is missing');
|
||||
} else if (!report.encryptionKey.valid) {
|
||||
problems.push('encryption.key is present but invalid');
|
||||
}
|
||||
if (report.auth.adminCount === 0) {
|
||||
problems.push('no admin users exist');
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
return { ok: false, message: `Validation failed:\n - ${problems.join('\n - ')}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: `Database OK (integrity ok, ${report.auth.adminCount} admin(s), encryption key valid).`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
exitWith(await validateDb());
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
Reference in New Issue
Block a user