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:
Anso
2026-06-02 16:11:24 -04:00
committed by GitHub
parent 06b25262cc
commit c6d1631afe
27 changed files with 1339 additions and 11 deletions
@@ -0,0 +1,65 @@
/**
* Route-level tests for GET /api/diagnostics: auth required, admin-only,
* response shape, and no secret leakage in the payload.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let adminAuthHeader: string;
let viewerAuthHeader: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ DatabaseService } = await import('../services/DatabaseService'));
adminAuthHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'diag-viewer', password_hash: viewerHash, role: 'viewer' });
viewerAuthHeader = `Bearer ${jwt.sign({ username: 'diag-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('GET /api/diagnostics', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/diagnostics');
expect(res.status).toBe(401);
});
it('rejects a non-admin', async () => {
const res = await request(app).get('/api/diagnostics').set('Authorization', viewerAuthHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('returns the diagnostics report for an admin', async () => {
const res = await request(app).get('/api/diagnostics').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body.database).toBeDefined();
expect(res.body.encryptionKey).toBeDefined();
expect(res.body.docker).toBeDefined();
expect(res.body.auth.adminCount).toBeGreaterThanOrEqual(1);
expect(Array.isArray(res.body.auth.ssoProviders)).toBe(true);
});
it('does not leak secret settings in the payload', async () => {
// Use a non-auth secret so overwriting it cannot break token verification
// (auth_jwt_secret is what the admin token is signed against).
DatabaseService.getInstance().updateGlobalSetting('cloud_backup_secret_key', 'route-secret-value');
const res = await request(app).get('/api/diagnostics').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain('route-secret-value');
expect(res.body.config.cloud_backup_secret_key).toBeUndefined();
});
});
@@ -0,0 +1,83 @@
/**
* Unit tests for DiagnosticsService.collectDiagnostics: the shape of the
* report, secret redaction (allowlist), Docker probe handling, and core-table
* detection. Shared by the /api/diagnostics route and the diagnostics/validate
* CLI commands, so these guarantees protect all three.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let collectDiagnostics: typeof import('../services/DiagnosticsService').collectDiagnostics;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ collectDiagnostics } = await import('../services/DiagnosticsService'));
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('collectDiagnostics', () => {
it('reports a healthy baseline database with at least one admin', async () => {
const report = await collectDiagnostics();
expect(report.database.ok).toBe(true);
expect(report.database.integrity).toBe('ok');
expect(report.database.missingTables).toEqual([]);
expect(report.auth.adminCount).toBeGreaterThanOrEqual(1);
expect(report.encryptionKey).toEqual({ present: true, valid: true });
});
it('reports docker unreachable when no probe is supplied', async () => {
const report = await collectDiagnostics();
expect(report.docker.reachable).toBe(false);
});
it('reflects a passed docker probe', async () => {
const reachable = await collectDiagnostics({ checkDocker: async () => true });
expect(reachable.docker.reachable).toBe(true);
const down = await collectDiagnostics({
checkDocker: async () => { throw new Error('socket closed'); },
});
expect(down.docker.reachable).toBe(false);
expect(down.docker.error).toBe('socket closed');
});
it('never surfaces secret settings in the config block', async () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('auth_jwt_secret', 'top-secret-signing-key');
db.updateGlobalSetting('cloud_backup_secret_key', 'enc:deadbeef');
db.updateGlobalSetting('host_cpu_limit', '80');
const report = await collectDiagnostics();
expect(report.config.auth_jwt_secret).toBeUndefined();
expect(report.config.cloud_backup_secret_key).toBeUndefined();
// The allowlisted, non-secret value is present.
expect(report.config.host_cpu_limit).toBe('80');
// No emitted value is one of the seeded secrets, regardless of key name.
expect(Object.values(report.config)).not.toContain('top-secret-signing-key');
expect(Object.values(report.config)).not.toContain('enc:deadbeef');
});
// Destructive: these table drops must run last in this file.
it('degrades instead of throwing when a read table is missing', async () => {
// sso_config is read by collectDiagnostics; dropping it must not throw,
// it must flag the table and fall back to an empty provider list.
DatabaseService.getInstance().getDb().exec('DROP TABLE sso_config');
const report = await collectDiagnostics();
expect(report.database.missingTables).toContain('sso_config');
expect(report.database.ok).toBe(false);
expect(report.auth.ssoProviders).toEqual([]);
});
it('flags a missing core table', async () => {
DatabaseService.getInstance().getDb().exec('DROP TABLE audit_log');
const report = await collectDiagnostics();
expect(report.database.missingTables).toContain('audit_log');
expect(report.database.ok).toBe(false);
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* Unit tests for the emergency recovery CLI command functions. Each command
* exports a testable function; we exercise it against a temporary seeded
* database and assert the database side effects and audit entries.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import bcrypt from 'bcrypt';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, TEST_USERNAME } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let resetPassword: typeof import('../cli/resetPassword').resetPassword;
let createEmergencyAdmin: typeof import('../cli/createEmergencyAdmin').createEmergencyAdmin;
let clearSessions: typeof import('../cli/clearSessions').clearSessions;
let disableSso: typeof import('../cli/disableSso').disableSso;
let validateDb: typeof import('../cli/validateDb').validateDb;
let backupData: typeof import('../cli/backupData').backupData;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ resetPassword } = await import('../cli/resetPassword'));
({ createEmergencyAdmin } = await import('../cli/createEmergencyAdmin'));
({ clearSessions } = await import('../cli/clearSessions'));
({ disableSso } = await import('../cli/disableSso'));
({ validateDb } = await import('../cli/validateDb'));
({ backupData } = await import('../cli/backupData'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('resetPassword', () => {
it('rejects a missing user', async () => {
const result = await resetPassword('nobody-here', 'newpassword123');
expect(result.ok).toBe(false);
expect(result.message).toContain('User not found');
});
it('rejects a too-short password', async () => {
const result = await resetPassword(TEST_USERNAME, 'short');
expect(result.ok).toBe(false);
expect(result.message).toContain('at least');
});
it('refuses to reset a non-local (SSO) account', async () => {
const db = DatabaseService.getInstance();
db.addUser({ username: 'sso-user', password_hash: 'unused', role: 'viewer', auth_provider: 'oidc_custom' });
const result = await resetPassword('sso-user', 'a-valid-password');
expect(result.ok).toBe(false);
expect(result.message).toContain('local accounts only');
});
it('resets the password and invalidates existing sessions', async () => {
const db = DatabaseService.getInstance();
const before = db.getUserByUsername(TEST_USERNAME)!;
const result = await resetPassword(TEST_USERNAME, 'brand-new-pass');
expect(result.ok).toBe(true);
const after = db.getUserByUsername(TEST_USERNAME)!;
expect(after.password_hash).not.toBe(before.password_hash);
expect(await bcrypt.compare('brand-new-pass', after.password_hash)).toBe(true);
expect(after.token_version).toBe(before.token_version + 1);
});
});
describe('createEmergencyAdmin', () => {
it('creates a new admin', async () => {
const db = DatabaseService.getInstance();
const result = await createEmergencyAdmin('rescue-admin', 'rescue-pass-1');
expect(result.ok).toBe(true);
const user = db.getUserByUsername('rescue-admin')!;
expect(user.role).toBe('admin');
expect(user.auth_provider).toBe('local');
});
it('refuses to overwrite an existing user', async () => {
const result = await createEmergencyAdmin(TEST_USERNAME, 'whatever-pass');
expect(result.ok).toBe(false);
expect(result.message).toContain('already exists');
});
it('rejects a malformed username', async () => {
const result = await createEmergencyAdmin('has space', 'valid-pass-1');
expect(result.ok).toBe(false);
expect(result.message).toContain('letters, numbers');
});
});
describe('clearSessions', () => {
it('bumps the token version of every user', () => {
const db = DatabaseService.getInstance();
const users = db.getUsers();
const before = users.map(u => db.getUserByUsername(u.username)!.token_version);
const result = clearSessions();
expect(result.ok).toBe(true);
const after = db.getUsers().map(u => db.getUserByUsername(u.username)!.token_version);
after.forEach((v, i) => expect(v).toBe(before[i] + 1));
});
});
describe('disableSso', () => {
it('disables a named provider and preserves its config', () => {
const db = DatabaseService.getInstance();
db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}');
const result = disableSso('oidc_custom');
expect(result.ok).toBe(true);
const config = db.getSSOConfig('oidc_custom')!;
expect(config.enabled).toBe(0);
expect(config.config_json).toBe('{"clientId":"abc"}');
});
it('reports cleanly when a provider is unknown', () => {
const result = disableSso('oidc_google');
expect(result.ok).toBe(false);
expect(result.message).toContain('No SSO config');
});
it('disables every enabled provider when no argument is given', () => {
const db = DatabaseService.getInstance();
db.upsertSSOConfig('ldap', true, '{"url":"ldap://x"}');
db.upsertSSOConfig('oidc_okta', true, '{"domain":"x"}');
const result = disableSso();
expect(result.ok).toBe(true);
expect(db.getEnabledSSOConfigs()).toHaveLength(0);
});
});
describe('backupData', () => {
it('refuses a destination that overwrites the live database', async () => {
const result = await backupData(process.env.DATA_DIR);
expect(result.ok).toBe(false);
expect(result.message).toContain('overwrite the live database');
});
it('writes a copy to a separate destination', async () => {
const dest = path.join(process.env.DATA_DIR as string, 'cli-backup');
const result = await backupData(dest);
expect(result.ok).toBe(true);
expect(fs.existsSync(path.join(dest, 'sencho.db'))).toBe(true);
});
});
describe('validateDb', () => {
it('passes on a healthy baseline database', async () => {
const result = await validateDb();
expect(result.ok).toBe(true);
expect(result.message).toContain('Database OK');
});
// Destructive: dropping a core table must be the last test in this file.
it('fails and names a missing core table', async () => {
DatabaseService.getInstance().getDb().exec('DROP TABLE audit_log');
const result = await validateDb();
expect(result.ok).toBe(false);
expect(result.message).toContain('audit_log');
});
});
+56
View File
@@ -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);
}
+57
View File
@@ -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();
}
+27
View File
@@ -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();
}
+49
View File
@@ -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();
}
+29
View File
@@ -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();
}
+50
View File
@@ -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();
}
+52
View File
@@ -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();
}
+48
View File
@@ -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();
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Shared username rule for account creation. Used by the user-management route
* and the emergency `create-emergency-admin` CLI so both enforce the same shape
* (no whitespace, slashes, or control characters that would produce odd
* accounts or malformed audit paths).
*
* Returns an error message, or null when the value is acceptable.
*/
export function validateUsername(value: unknown): string | null {
if (typeof value !== 'string' || value.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(value)) {
return 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)';
}
return null;
}
+2
View File
@@ -53,6 +53,7 @@ import { stackMetricsRouter } from './routes/stackMetrics';
import { fileExplorerMetricsRouter } from './routes/fileExplorerMetrics';
import { stackActivityMetricsRouter } from './routes/stackActivityMetrics';
import { secretsRouter } from './routes/secrets';
import { diagnosticsRouter } from './routes/diagnostics';
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
// util._extend internally. The warning fires at runtime when createProxyServer() is
@@ -141,6 +142,7 @@ app.use('/api/security', securityRouter);
app.use('/api/containers', containersRouter);
app.use('/api/ports', portsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/diagnostics', diagnosticsRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/stacks', stackActivityRouter);
app.use('/api/stacks', stacksRouter);
+38
View File
@@ -0,0 +1,38 @@
import { Router, type Request, type Response } from 'express';
import { requireAdmin, requireUserSession } from '../middleware/tierGates';
import { collectDiagnostics } from '../services/DiagnosticsService';
import DockerController from '../services/DockerController';
import { withTimeout } from '../utils/withTimeout';
export const diagnosticsRouter = Router();
const DOCKER_PING_TIMEOUT_MS = 2000;
// Recovery diagnostics for the local control plane. Restricted to a genuine
// signed-in admin session: requireUserSession rejects API tokens and
// node_proxy / pilot_tunnel machine credentials so a long-lived machine token
// cannot read the control plane's configuration inventory. Read-only and
// secret-free (see DiagnosticsService for the redaction allowlist). The Docker
// probe is bounded and wrapped so a down or hung daemon yields
// `docker.reachable: false` instead of failing the whole request, which is the
// exact condition an operator opens this surface to diagnose.
diagnosticsRouter.get('/', async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const report = await collectDiagnostics({
checkDocker: async () => {
await withTimeout(
DockerController.getInstance().getDocker().ping(),
DOCKER_PING_TIMEOUT_MS,
'docker-ping',
);
return true;
},
});
res.json(report);
} catch (err) {
console.error('[diagnostics] failed to collect report:', (err as Error).message);
res.status(500).json({ error: 'Failed to collect diagnostics.' });
}
});
+1 -7
View File
@@ -10,6 +10,7 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
import { validateUsername } from '../helpers/validateUsername';
const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.';
const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
@@ -23,13 +24,6 @@ function roleRequiresAdmiral(role: UserRole): boolean {
return role === 'deployer' || role === 'node-admin' || role === 'auditor';
}
function validateUsername(value: unknown): string | null {
if (typeof value !== 'string' || value.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(value)) {
return 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)';
}
return null;
}
// Returns a seat-limit error message if adding an account of `role` would
// exceed the current license seat caps, or null when within limits. Counts are
// read at call time so the check reflects live state. Used by both user
+16
View File
@@ -2680,12 +2680,28 @@ export class DatabaseService {
this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ? WHERE id = ?').run(Date.now(), userId);
}
/**
* Invalidate every active session by bumping every user's token_version in
* one statement. Returns the number of users affected. Used by the
* emergency `clear-sessions` CLI when a stolen cookie or a wedged login
* state needs a clean sign-out of every user on this node.
*/
public bumpAllTokenVersions(): number {
const result = this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ?').run(Date.now());
return result.changes;
}
// --- User MFA ---
public getUserMfa(userId: number): UserMfa | undefined {
return this.db.prepare('SELECT * FROM user_mfa WHERE user_id = ?').get(userId) as UserMfa | undefined;
}
/** Count of users with a completed (enabled) two-factor enrolment. */
public getMfaEnrolledCount(): number {
return (this.db.prepare('SELECT COUNT(*) as count FROM user_mfa WHERE enabled = 1').get() as { count: number })?.count || 0;
}
/**
* Create or merge a user_mfa row. Any field left undefined on the update
* object is preserved. Boolean flags are normalized to 0/1.
+174
View File
@@ -0,0 +1,174 @@
/**
* Read-only recovery diagnostics shared by the admin Recovery settings tab
* (GET /api/diagnostics) and the `diagnostics` / `validate-db` emergency CLI
* commands. It answers the first questions an operator asks when the dashboard
* is misbehaving: is the database intact, is the encryption key present, is
* Docker reachable, and is at least one admin able to sign in.
*
* The report carries no secrets. The `config` block is built from an allowlist
* of non-sensitive global settings, so tokens, password hashes, OIDC client
* secrets, and cloud-backup credentials can never leak into an exported bundle.
*/
import fs from 'fs';
import path from 'path';
import { DatabaseService } from './DatabaseService';
import { CryptoService } from './CryptoService';
import { getSenchoVersion } from './CapabilityRegistry';
// Tables the app cannot function without. A missing one means the schema did
// not initialize and the database should be treated as broken.
const CORE_TABLES = ['users', 'global_settings', 'sso_config', 'audit_log'] as const;
// Allowlist of global_settings keys safe to surface. Anything not listed here
// (auth_jwt_secret, auth_password_hash, cloud_backup_secret_key, OIDC/LDAP
// secrets, etc.) is omitted by construction rather than filtered out.
const SAFE_SETTING_KEYS = [
'host_cpu_limit',
'host_ram_limit',
'host_disk_limit',
'host_alert_suppression_mins',
'docker_janitor_gb',
'global_crash',
'developer_mode',
'template_registry_url',
'metrics_retention_hours',
'log_retention_days',
'audit_retention_days',
'mesh_auto_recreate',
'scan_history_per_image_limit',
'cloud_backup_provider',
] as const;
export interface DiagnosticsReport {
version: string | null;
database: {
ok: boolean;
integrity: string;
path: string;
missingTables: string[];
};
encryptionKey: { present: boolean; valid: boolean };
docker: { reachable: boolean; error?: string };
auth: {
adminCount: number;
userCount: number;
mfaEnrolledCount: number;
ssoProviders: Array<{ provider: string; enabled: boolean }>;
};
config: Record<string, string>;
}
export interface CollectDiagnosticsOptions {
/**
* Optional Docker reachability probe. The HTTP route passes a bounded
* `docker.ping()`; the CLI omits it because it runs without a Docker
* connection and reports `reachable: false`.
*/
checkDocker?: () => Promise<boolean>;
}
function dataDir(): string {
return process.env.DATA_DIR || path.join(process.cwd(), 'data');
}
/**
* Confirm the encryption key is on disk and actually usable: present, a 32-byte
* hex value, and able to round-trip through CryptoService. A present-but-corrupt
* key reads as `{ present: true, valid: false }` so the operator knows a restore
* is needed rather than a fresh key generation.
*/
function checkEncryptionKey(): { present: boolean; valid: boolean } {
const keyPath = path.join(dataDir(), 'encryption.key');
if (!fs.existsSync(keyPath)) return { present: false, valid: false };
try {
const raw = fs.readFileSync(keyPath, 'utf-8').trim();
if (Buffer.from(raw, 'hex').length !== 32) return { present: true, valid: false };
const crypto = CryptoService.getInstance();
const probe = crypto.encrypt('diagnostics-probe');
return { present: true, valid: crypto.decrypt(probe) === 'diagnostics-probe' };
} catch (err) {
// The UI verdict is binary, but log the cause so an unreadable key file
// (permissions) can be told apart from a corrupt one when debugging.
console.warn(`[diagnostics] encryption key check failed: ${(err as Error).message}`);
return { present: true, valid: false };
}
}
export async function collectDiagnostics(opts: CollectDiagnosticsOptions = {}): Promise<DiagnosticsReport> {
const db = DatabaseService.getInstance();
const handle = db.getDb();
// A read that may throw if its table is missing or corrupt degrades to
// `fallback` instead of failing the whole report, and records that a read
// failed so `database.ok` reflects it. This keeps the surface usable on the
// broken database it exists to diagnose, without reporting a degraded `0`
// (e.g. adminCount) as if the database were healthy.
let readFailed = false;
const safe = <T>(read: () => T, fallback: T, label: string): T => {
try {
return read();
} catch (err) {
readFailed = true;
console.warn(`[diagnostics] ${label} failed: ${String((err as Error)?.message ?? err)}`);
return fallback;
}
};
let integrity: string;
let missingTables: string[];
let integrityOk = false;
try {
integrity = String(handle.pragma('integrity_check', { simple: true }));
const present = new Set(
(handle.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>)
.map(row => row.name),
);
missingTables = CORE_TABLES.filter(table => !present.has(table));
integrityOk = integrity === 'ok';
} catch (err) {
integrity = `error: ${(err as Error).message}`;
missingTables = [];
}
const config = safe(() => {
const settings = db.getGlobalSettings();
const out: Record<string, string> = {};
for (const key of SAFE_SETTING_KEYS) {
if (settings[key] !== undefined) out[key] = settings[key];
}
return out;
}, {}, 'global_settings read');
const auth = {
adminCount: safe(() => db.getAdminCount(), 0, 'getAdminCount'),
userCount: safe(() => db.getUserCount(), 0, 'getUserCount'),
mfaEnrolledCount: safe(() => db.getMfaEnrolledCount(), 0, 'getMfaEnrolledCount'),
ssoProviders: safe(
() => db.getSSOConfigs().map(c => ({ provider: c.provider, enabled: c.enabled === 1 })),
[] as Array<{ provider: string; enabled: boolean }>,
'getSSOConfigs',
),
};
// Reads run before this so a present-but-unreadable table (which the
// integrity check may still call "ok") also marks the database not-ok.
const dbOk = integrityOk && missingTables.length === 0 && !readFailed;
let docker: DiagnosticsReport['docker'] = { reachable: false };
if (opts.checkDocker) {
try {
docker = { reachable: await opts.checkDocker() };
} catch (err) {
docker = { reachable: false, error: (err as Error).message };
}
}
return {
version: getSenchoVersion(),
database: { ok: dbOk, integrity, path: handle.name, missingTables },
encryptionKey: checkEncryptionKey(),
docker,
auth,
config,
};
}