From c6d1631afe9be235d12cc4c86f841b879c876677 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 2 Jun 2026 16:11:24 -0400 Subject: [PATCH] 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. --- backend/package.json | 9 +- .../src/__tests__/diagnostics-route.test.ts | 65 ++++ .../src/__tests__/diagnostics-service.test.ts | 83 ++++++ backend/src/__tests__/recovery-cli.test.ts | 160 ++++++++++ backend/src/cli/_shared.ts | 56 ++++ backend/src/cli/backupData.ts | 57 ++++ backend/src/cli/clearSessions.ts | 27 ++ backend/src/cli/createEmergencyAdmin.ts | 49 +++ backend/src/cli/diagnostics.ts | 29 ++ backend/src/cli/disableSso.ts | 50 ++++ backend/src/cli/resetPassword.ts | 52 ++++ backend/src/cli/validateDb.ts | 48 +++ backend/src/helpers/validateUsername.ts | 14 + backend/src/index.ts | 2 + backend/src/routes/diagnostics.ts | 38 +++ backend/src/routes/users.ts | 8 +- backend/src/services/DatabaseService.ts | 16 + backend/src/services/DiagnosticsService.ts | 174 +++++++++++ docs/docs.json | 1 + docs/operations/emergency-cli.mdx | 110 +++++++ docs/operations/recovery.mdx | 7 +- docs/operations/two-factor-admin.mdx | 2 + .../components/settings/RecoverySection.tsx | 278 ++++++++++++++++++ .../src/components/settings/SettingsPage.tsx | 2 + frontend/src/components/settings/index.ts | 1 + frontend/src/components/settings/registry.ts | 11 + frontend/src/components/settings/types.ts | 1 + 27 files changed, 1339 insertions(+), 11 deletions(-) create mode 100644 backend/src/__tests__/diagnostics-route.test.ts create mode 100644 backend/src/__tests__/diagnostics-service.test.ts create mode 100644 backend/src/__tests__/recovery-cli.test.ts create mode 100644 backend/src/cli/_shared.ts create mode 100644 backend/src/cli/backupData.ts create mode 100644 backend/src/cli/clearSessions.ts create mode 100644 backend/src/cli/createEmergencyAdmin.ts create mode 100644 backend/src/cli/diagnostics.ts create mode 100644 backend/src/cli/disableSso.ts create mode 100644 backend/src/cli/resetPassword.ts create mode 100644 backend/src/cli/validateDb.ts create mode 100644 backend/src/helpers/validateUsername.ts create mode 100644 backend/src/routes/diagnostics.ts create mode 100644 backend/src/services/DiagnosticsService.ts create mode 100644 docs/operations/emergency-cli.mdx create mode 100644 frontend/src/components/settings/RecoverySection.tsx diff --git a/backend/package.json b/backend/package.json index 22fe2f57..2162fc04 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,14 @@ "dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts", "test": "vitest run", "lint": "eslint src", - "reset-mfa": "node dist/cli/resetMfa.js" + "reset-mfa": "node dist/cli/resetMfa.js", + "reset-password": "node dist/cli/resetPassword.js", + "create-emergency-admin": "node dist/cli/createEmergencyAdmin.js", + "clear-sessions": "node dist/cli/clearSessions.js", + "disable-sso": "node dist/cli/disableSso.js", + "diagnostics": "node dist/cli/diagnostics.js", + "validate-db": "node dist/cli/validateDb.js", + "backup-data": "node dist/cli/backupData.js" }, "keywords": [], "author": "", diff --git a/backend/src/__tests__/diagnostics-route.test.ts b/backend/src/__tests__/diagnostics-route.test.ts new file mode 100644 index 00000000..c95c450c --- /dev/null +++ b/backend/src/__tests__/diagnostics-route.test.ts @@ -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(); + }); +}); diff --git a/backend/src/__tests__/diagnostics-service.test.ts b/backend/src/__tests__/diagnostics-service.test.ts new file mode 100644 index 00000000..dec9f04b --- /dev/null +++ b/backend/src/__tests__/diagnostics-service.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/recovery-cli.test.ts b/backend/src/__tests__/recovery-cli.test.ts new file mode 100644 index 00000000..3f05e375 --- /dev/null +++ b/backend/src/__tests__/recovery-cli.test.ts @@ -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'); + }); +}); diff --git a/backend/src/cli/_shared.ts b/backend/src/cli/_shared.ts new file mode 100644 index 00000000..948b5538 --- /dev/null +++ b/backend/src/cli/_shared.ts @@ -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); +} diff --git a/backend/src/cli/backupData.ts b/backend/src/cli/backupData.ts new file mode 100644 index 00000000..542a0b89 --- /dev/null +++ b/backend/src/cli/backupData.ts @@ -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 /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 { + 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 { + exitWith(await backupData(process.argv[2])); +} + +if (require.main === module) { + void main(); +} diff --git a/backend/src/cli/clearSessions.ts b/backend/src/cli/clearSessions.ts new file mode 100644 index 00000000..3878dc57 --- /dev/null +++ b/backend/src/cli/clearSessions.ts @@ -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(); +} diff --git a/backend/src/cli/createEmergencyAdmin.ts b/backend/src/cli/createEmergencyAdmin.ts new file mode 100644 index 00000000..6d9221a5 --- /dev/null +++ b/backend/src/cli/createEmergencyAdmin.ts @@ -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 + * + * 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 { + 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 { + const username = process.argv[2]; + const password = process.argv[3]; + if (!username || !password) { + usage('Usage: node dist/cli/createEmergencyAdmin.js '); + } + exitWith(await createEmergencyAdmin(username, password)); +} + +if (require.main === module) { + void main(); +} diff --git a/backend/src/cli/diagnostics.ts b/backend/src/cli/diagnostics.ts new file mode 100644 index 00000000..00917cb6 --- /dev/null +++ b/backend/src/cli/diagnostics.ts @@ -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 { + const report = await collectDiagnostics(); + console.log(JSON.stringify(report, null, 2)); +} + +async function main(): Promise { + 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(); +} diff --git a/backend/src/cli/disableSso.ts b/backend/src/cli/disableSso.ts new file mode 100644 index 00000000..63c53d3c --- /dev/null +++ b/backend/src/cli/disableSso.ts @@ -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(); +} diff --git a/backend/src/cli/resetPassword.ts b/backend/src/cli/resetPassword.ts new file mode 100644 index 00000000..83ebdd69 --- /dev/null +++ b/backend/src/cli/resetPassword.ts @@ -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 + * + * 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 { + 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 { + const username = process.argv[2]; + const newPassword = process.argv[3]; + if (!username || !newPassword) { + usage('Usage: node dist/cli/resetPassword.js '); + } + exitWith(await resetPassword(username, newPassword)); +} + +if (require.main === module) { + void main(); +} diff --git a/backend/src/cli/validateDb.ts b/backend/src/cli/validateDb.ts new file mode 100644 index 00000000..9f65b10f --- /dev/null +++ b/backend/src/cli/validateDb.ts @@ -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 { + 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 { + exitWith(await validateDb()); +} + +if (require.main === module) { + void main(); +} diff --git a/backend/src/helpers/validateUsername.ts b/backend/src/helpers/validateUsername.ts new file mode 100644 index 00000000..94cacfe6 --- /dev/null +++ b/backend/src/helpers/validateUsername.ts @@ -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; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 0524b100..401965e9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/diagnostics.ts b/backend/src/routes/diagnostics.ts new file mode 100644 index 00000000..e944e87c --- /dev/null +++ b/backend/src/routes/diagnostics.ts @@ -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 => { + 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.' }); + } +}); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index fb323938..c0992365 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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 diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index fa9e0059..77e166c6 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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. diff --git a/backend/src/services/DiagnosticsService.ts b/backend/src/services/DiagnosticsService.ts new file mode 100644 index 00000000..d87f3806 --- /dev/null +++ b/backend/src/services/DiagnosticsService.ts @@ -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; +} + +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; +} + +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 { + 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 = (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 = {}; + 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, + }; +} diff --git a/docs/docs.json b/docs/docs.json index d8d9ee08..428cd88b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -180,6 +180,7 @@ "operations/upgrade", "operations/backup", "operations/recovery", + "operations/emergency-cli", "operations/troubleshooting", "operations/verifying-images", "operations/trivy-setup", diff --git a/docs/operations/emergency-cli.mdx b/docs/operations/emergency-cli.mdx new file mode 100644 index 00000000..6ce6daaa --- /dev/null +++ b/docs/operations/emergency-cli.mdx @@ -0,0 +1,110 @@ +--- +title: Emergency command-line recovery +description: Host-level commands that recover access when the Sencho UI is unreachable, plus the in-app Recovery surface for everyday health checks. +--- + +When the dashboard is unreachable, an identity provider blocks the login screen, or every administrator is locked out, Sencho ships a set of emergency commands you run from a shell on the host. Each one acts on the same SQLite database the application uses (it respects the container's `DATA_DIR`), and each writes an audit-log entry attributed to `cli` where it changes state, so the action is traceable afterwards. + +All commands run through the Sencho container: + +```bash +docker compose exec sencho node dist/cli/.js [arguments] +``` + +Each command prints what it did and exits with code `0` on success or non-zero on failure, so it can gate a script. + + + Before you rely on Sencho in earnest, back up `DATA_DIR` and `COMPOSE_DIR`. The [Recovery guide](/operations/recovery) explains why most recovery is file-level, and [Backup & Restore](/operations/backup) gives a copy-and-restore routine. + + +## In-app Recovery surface + +For everyday checks you do not need a shell. An administrator can open **Settings · Recovery** for a read-only health snapshot: app version, database integrity, encryption-key status, Docker reachability, administrator and two-factor counts, and the non-secret configuration. The page loads without Docker or live metrics, so it stays available when the rest of the dashboard does not. From there you can export the snapshot as JSON, reset this browser's interface preferences to defaults, and see the same command reference printed below. + +The command line is the fallback for when even that surface cannot be reached. + +## Sign-in and account recovery + +### Reset a forgotten password + +Resets a local user's password and signs out their existing sessions. + +```bash +docker compose exec sencho node dist/cli/resetPassword.js +``` + +Applies to local accounts only. Users who sign in through SSO are managed by their identity provider. + +### Create an emergency administrator + +Creates a fresh local administrator when every existing admin is locked out but the database is otherwise intact, so you avoid discarding configuration with a full setup reset. + +```bash +docker compose exec sencho node dist/cli/createEmergencyAdmin.js +``` + +It refuses to overwrite an existing user; use the password reset above for an account that already exists. Sign in afterwards and review your other accounts. + +### Clear the existing two-factor enrolment + +Removes a user's second factor so they can sign in with their password alone and re-enrol. + +```bash +docker compose exec sencho node dist/cli/resetMfa.js +``` + +See [Managing Two-Factor Authentication](/operations/two-factor-admin) for the full two-factor workflow, including the in-app reset another admin can perform without a shell. + +### Sign every user out + +Invalidates every active session at once, for example after a suspected stolen session cookie. + +```bash +docker compose exec sencho node dist/cli/clearSessions.js +``` + +Everyone signs in again on their next request; nothing else changes. + +### Disable a broken single sign-on provider + +Re-enables local password sign-in when a misconfigured identity provider blocks the login screen. + +```bash +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 you can correct it and turn it back on from **Settings · SSO**. + +## Inspecting and protecting your data + +### Print a diagnostic summary + +Prints the same snapshot the in-app Recovery surface shows, as JSON. Read-only and free of secrets, so it is safe to paste into a bug report. + +```bash +docker compose exec sencho node dist/cli/diagnostics.js +``` + +### Validate the database and encryption key + +Runs a database integrity check, confirms the core tables exist, confirms the encryption key is present and usable, and confirms at least one administrator exists. Exits non-zero if any check fails. + +```bash +docker compose exec sencho node dist/cli/validateDb.js +``` + +Use this to decide whether a restore is needed before you start the application against a questionable data directory. + +### Back up the data directory + +Writes a consistent copy of `sencho.db` (using SQLite's online backup, safe while Sencho is running) and `encryption.key` to a target directory. + +```bash +docker compose exec sencho node dist/cli/backupData.js [destination-dir] +``` + +With no argument it writes a timestamped folder under `DATA_DIR/backups`. The copy contains your encryption key, so store it as carefully as the original. + + + These commands act directly on live data. Read the description of each before running it, and prefer the least disruptive option that solves your problem. None of them touches your Docker stacks or their compose files; those are independent of Sencho. + diff --git a/docs/operations/recovery.mdx b/docs/operations/recovery.mdx index 1aba524f..688cff11 100644 --- a/docs/operations/recovery.mdx +++ b/docs/operations/recovery.mdx @@ -65,13 +65,14 @@ See [Troubleshooting: containers won't start after deploy](/operations/troublesh 1. **Another admin is still in:** any administrator can reset a locked-out user's two-factor enrolment from **Settings · Users**. The user then signs in with their password alone and re-enrols. 2. **Every admin has lost 2FA:** reset two-factor for the admin account directly on the host with the emergency command-line reset. It acts on the same database the app uses and records the action in the audit log. -3. **The admin password itself is forgotten:** Sencho has no password-reset email flow. The recovery path is to reset first-boot setup by removing `sencho.db`, which lets you create a fresh admin account. +3. **The admin password itself is forgotten:** Sencho has no password-reset email flow, but you do not need to discard your configuration. From a shell on the host, reset the password with the emergency command line, or create a fresh emergency administrator. Both act on the live database and leave your nodes, alert rules, and labels untouched. +4. **Last resort, the database is unusable:** reset first-boot setup by removing `sencho.db`, which lets you create a fresh admin account from scratch. - Removing `sencho.db` resets all Sencho configuration: users, nodes, alert rules, notification settings, and labels. Your Docker stacks and their compose files are not affected, and Sencho re-discovers them on the next scan. Use this only when no administrator can sign in. + Removing `sencho.db` resets all Sencho configuration: users, nodes, alert rules, notification settings, and labels. Your Docker stacks and their compose files are not affected, and Sencho re-discovers them on the next scan. Use this only when no administrator can sign in and the command-line recovery above is not enough. -See [Managing Two-Factor Authentication](/operations/two-factor-admin) for the admin reset and the emergency CLI, and [Troubleshooting: forgotten admin password](/operations/troubleshooting#forgotten-admin-password) for the database-reset path. +See [Emergency command-line recovery](/operations/emergency-cli) for the full command set, [Managing Two-Factor Authentication](/operations/two-factor-admin) for the admin two-factor reset, and [Troubleshooting: forgotten admin password](/operations/troubleshooting#forgotten-admin-password) for the database-reset path. --- diff --git a/docs/operations/two-factor-admin.mdx b/docs/operations/two-factor-admin.mdx index fbe698cd..91d1b3b8 100644 --- a/docs/operations/two-factor-admin.mdx +++ b/docs/operations/two-factor-admin.mdx @@ -45,6 +45,8 @@ Replace `` with the admin's account name. On success the command print The command respects the container's `DATA_DIR`, so it always acts on the same SQLite database the application uses. It writes an audit-log entry attributed to `cli` so the action is auditable after the fact. +This is one of several host-level recovery commands. See [Emergency command-line recovery](/operations/emergency-cli) for the full set, including password reset, creating an emergency admin, and disabling a broken SSO provider. + Enabling **Developer Mode** under **Settings · Developer** surfaces additional `[MFA:diag]` lines in the backend logs. They are helpful when investigating a 2FA support ticket and can be turned off again once the issue is resolved. diff --git a/frontend/src/components/settings/RecoverySection.tsx b/frontend/src/components/settings/RecoverySection.tsx new file mode 100644 index 00000000..5c0d5618 --- /dev/null +++ b/frontend/src/components/settings/RecoverySection.tsx @@ -0,0 +1,278 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import { RefreshCw, Download, Check, X, AlertTriangle, RotateCcw, ExternalLink } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import { Skeleton } from '@/components/ui/skeleton'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; +import { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions'; +import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled'; +import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled'; + +// Mirrors the backend DiagnosticsReport (services/DiagnosticsService.ts). Kept +// local because the frontend cannot import backend types. +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; +} + +type Health = 'ok' | 'warn' | 'error'; + +// Browser-local display preferences cleared by "Reset interface preferences". +// The density key is internal to use-density; the other two are exported. +const DENSITY_KEY = 'sencho.appearance.density'; +const INTERFACE_PREF_KEYS = [DENSITY_KEY, DEPLOY_FEEDBACK_KEY, COMPOSE_DIFF_PREVIEW_KEY]; + +const CLI_COMMANDS: Array<{ cmd: string; purpose: string }> = [ + { cmd: 'node dist/cli/resetMfa.js ', purpose: "Clear a user's two-factor enrolment" }, + { cmd: 'node dist/cli/resetPassword.js ', purpose: "Reset a local user's password" }, + { cmd: 'node dist/cli/createEmergencyAdmin.js ', purpose: 'Create a new admin account' }, + { cmd: 'node dist/cli/clearSessions.js', purpose: 'Sign every user out' }, + { cmd: 'node dist/cli/disableSso.js [provider]', purpose: 'Disable a broken SSO provider' }, + { cmd: 'node dist/cli/diagnostics.js', purpose: 'Print this report as JSON' }, + { cmd: 'node dist/cli/validateDb.js', purpose: 'Check database and encryption-key integrity' }, + { cmd: 'node dist/cli/backupData.js [dir]', purpose: 'Back up the data directory' }, +]; + +// When Docker is unreachable, prefer the actual error the backend captured (a +// bad socket path or permission denial is not self-healing); fall back to the +// reassuring copy only when no specific cause was reported. +function dockerHelper(report: DiagnosticsReport | null): string | undefined { + if (!report || report.docker.reachable) return undefined; + return report.docker.error + ? `Unreachable: ${report.docker.error}` + : 'Sencho reconnects on its own once Docker is back.'; +} + +// Save text content to a file via a transient object URL. Used for both the +// diagnostics JSON export and the offline command reference. +function triggerDownload(filename: string, content: string, mime: string) { + const url = URL.createObjectURL(new Blob([content], { type: mime })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +// Plain-text command reference an operator can save while the app is reachable, +// so the commands are on hand for exactly the situation where it is not. +function cliReferenceText(): string { + const lines = [ + 'Sencho emergency recovery commands', + '', + 'Run each from a shell on the host running Sencho:', + ' docker compose exec sencho ', + '', + ]; + for (const { cmd, purpose } of CLI_COMMANDS) { + lines.push(`# ${purpose}`, `docker compose exec sencho ${cmd}`, ''); + } + return lines.join('\n'); +} + +function StatusValue({ health, children }: { health: Health; children: ReactNode }) { + const Icon = health === 'ok' ? Check : health === 'warn' ? AlertTriangle : X; + return ( + + + {children} + + ); +} + +function RecoverySkeleton() { + return ( +
+ + + + +
+ ); +} + +export function RecoverySection() { + const [report, setReport] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const res = await apiFetch('/diagnostics', { localOnly: true }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || 'Failed to load diagnostics.'); + setReport(null); + return; + } + setReport(await res.json() as DiagnosticsReport); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Failed to load diagnostics.'); + setReport(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void load(); + }, [load]); + + const exportReport = () => { + if (!report) return; + try { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + triggerDownload(`sencho-diagnostics-${stamp}.json`, JSON.stringify(report, null, 2), 'application/json'); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Could not export diagnostics.'); + } + }; + + const downloadCommands = () => { + try { + triggerDownload('sencho-recovery-commands.txt', cliReferenceText(), 'text/plain'); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Could not download the command reference.'); + } + }; + + const resetInterface = () => { + try { + INTERFACE_PREF_KEYS.forEach(key => window.localStorage.removeItem(key)); + toast.success('Interface preferences reset to defaults. Reloading...'); + setTimeout(() => window.location.reload(), 600); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Could not reset interface preferences.'); + } + }; + + if (isLoading) return ; + + const dbHealth: Health = report?.database.ok ? 'ok' : 'error'; + const keyHealth: Health = report?.encryptionKey.present && report.encryptionKey.valid ? 'ok' : 'error'; + const dockerHealth: Health = report?.docker.reachable ? 'ok' : 'warn'; + const adminHealth: Health = (report?.auth.adminCount ?? 0) > 0 ? 'ok' : 'warn'; + + return ( +
+ + + {report?.version ?? 'unknown'} + + + {report?.database.ok ? 'Healthy' : 'Problem detected'} + + + + {!report?.encryptionKey.present ? 'Missing' : report.encryptionKey.valid ? 'Present' : 'Invalid'} + + + + {report?.docker.reachable ? 'Reachable' : 'Unreachable'} + + + {report?.auth.adminCount ?? 0} of {report?.auth.userCount ?? 0} users + + + {report?.auth.mfaEnrolledCount ?? 0} user(s) + + + + {report && report.auth.ssoProviders.length > 0 + ? report.auth.ssoProviders.map(p => `${p.provider} (${p.enabled ? 'on' : 'off'})`).join(', ') + : 'None configured'} + + + + + void load()}> + + Refresh + + + + Export diagnostics + + + + + + + + + Reset + + + + + Open guide + + + + + + +
+

+ prefix each with: docker compose exec sencho +

+ {CLI_COMMANDS.map(({ cmd, purpose }) => ( +
+ {cmd} + {purpose} +
+ ))} +
+ + + + Download commands + + +
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index be26d1af..f0f0ab0a 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -25,6 +25,7 @@ import { AppStoreSection, SupportSection, AboutSection, + RecoverySection, SETTINGS_ITEMS, SETTINGS_GROUPS, getSettingsItem, @@ -200,6 +201,7 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp case 'developer': return handleDirtyChange('developer', d)} />; case 'nodes': return ; case 'app-store': return ; + case 'recovery': return ; case 'support': return ; case 'about': return ; default: return null; diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 2b07e29e..1d43487e 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -9,6 +9,7 @@ export { DeveloperSection } from './DeveloperSection'; export { AppStoreSection } from './AppStoreSection'; export { SupportSection } from './SupportSection'; export { AboutSection } from './AboutSection'; +export { RecoverySection } from './RecoverySection'; // Paid-tier sections (UsersSection, WebhooksSection, SecuritySection, // LabelsSection, CloudBackupSection, NotificationRoutingSection) are NOT diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index acad18a8..4423aad7 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -202,6 +202,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ tier: null, scope: 'node', }, + { + id: 'recovery', + group: 'advanced', + label: 'Recovery', + description: 'System health snapshot, safe recovery actions, and emergency command-line reference.', + keywords: ['recovery', 'safe mode', 'diagnostics', 'health', 'emergency', 'cli', 'reset', 'backup', 'restore'], + tier: null, + scope: 'global', + adminOnly: true, + hiddenOnRemote: true, + }, { id: 'support', group: 'advanced', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 6a4ae39d..33f72047 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -48,6 +48,7 @@ export type SectionId = | 'nodes' | 'app-store' | 'notification-routing' + | 'recovery' | 'support' | 'about';