mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
feat(recovery): add safe-mode recovery surface and emergency CLI (#1286)
* feat(recovery): add safe-mode recovery surface and emergency CLI Add a read-only Recovery tab under Settings (admin-only) backed by a new GET /api/diagnostics endpoint reporting app version, database integrity, encryption-key status, Docker reachability, account and SSO counts, and non-secret configuration. The endpoint loads without Docker or live metrics so it stays available when the dashboard does not, requires a genuine admin session, and builds its config block from a non-secret allowlist so no credentials are ever exposed. Expand the emergency command-line toolkit beyond the two-factor reset with seven host-level commands: reset-password, create-emergency-admin, clear-sessions, disable-sso, diagnostics, validate-db, and backup-data. Each prints its result, exits with a meaningful status code, and writes an audit entry where it changes state. Document the toolkit in a new operator guide and link it from the recovery and two-factor pages. * feat(recovery): download the emergency command reference as a text file The recovery commands are needed exactly when the dashboard is unreachable, so reading them only in-app is a chicken-and-egg problem. Add a Download button to the command-line section that saves the full `docker compose exec sencho ...` reference as a text file, letting operators keep it on hand before they need it. Reuses a shared download helper with the existing diagnostics export. * fix(recovery): harden diagnostics, backup, and emergency-admin against edge cases Address findings from an independent review of the recovery toolkit: - DiagnosticsService now degrades instead of throwing when a queried table is missing or corrupt: each read falls back and is folded into database.ok, so a broken database reports "problem detected" rather than failing the whole endpoint or showing a misleading healthy state with zeroed counts. - backup-data refuses a destination that resolves to the live database, which would otherwise report success while producing no separate copy. - create-emergency-admin now applies the same username rule as the user- management route, extracted to a shared helper so both stay in sync. Adds tests for a missing read table, a malformed emergency-admin username, and the backup same-target rejection.
This commit is contained in:
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Shared helpers for the emergency recovery CLI commands in this directory.
|
||||
* Each command exports a testable function and a thin `main()` wrapper; these
|
||||
* helpers carry the audit-write and exit-code conventions so the individual
|
||||
* commands stay focused on their own logic.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
export interface CliResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a CLI action in the audit log with the conventional `cli` actor, then
|
||||
* flush immediately. The buffer's 1s flush timer never fires because the CLI
|
||||
* process exits first, so the explicit flush is what persists the entry. An
|
||||
* audit-write failure is logged but never aborts the action it was recording.
|
||||
*/
|
||||
export function auditCli(db: DatabaseService, path: string, summary: string): void {
|
||||
try {
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: 'cli',
|
||||
method: 'POST',
|
||||
path,
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: 'cli',
|
||||
summary,
|
||||
});
|
||||
// Flush inside the try: the buffer's 1s timer never fires before the
|
||||
// CLI exits, so this is what persists the entry. Keeping it under the
|
||||
// same catch means a broken DB handle cannot throw a second, uncaught
|
||||
// error after the recovery action it records has already succeeded.
|
||||
db.flushAuditLogBuffer();
|
||||
} catch (err) {
|
||||
console.warn(`[cli] audit log write failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Print the result and exit 0 (ok) or 1 (error), matching resetMfa.ts. */
|
||||
export function exitWith(result: CliResult): never {
|
||||
if (result.ok) {
|
||||
console.log(result.message);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(result.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Print a usage error to stderr and exit 2 (matches resetMfa.ts). */
|
||||
export function usage(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Emergency CLI: back up the Sencho data directory (sencho.db + encryption.key)
|
||||
* to a target directory. Uses SQLite's online backup so the copy is consistent
|
||||
* even while Sencho is running.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/backupData.js [destination-dir]
|
||||
*
|
||||
* With no argument it writes a timestamped folder under <DATA_DIR>/backups.
|
||||
* Written to the audit log with actor `cli`.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
function dataDir(): string {
|
||||
return process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
}
|
||||
|
||||
export async function backupData(destArg?: string): Promise<CliResult> {
|
||||
const src = dataDir();
|
||||
const keyPath = path.join(src, 'encryption.key');
|
||||
const trimmedDest = destArg?.trim();
|
||||
const dest = trimmedDest
|
||||
? path.resolve(trimmedDest)
|
||||
: path.join(src, 'backups', `sencho-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
// Refuse a destination that would write the copy onto the live database
|
||||
// itself, which would report success while producing no separate backup.
|
||||
if (path.resolve(dest, 'sencho.db') === path.resolve(db.getDb().name)) {
|
||||
return { ok: false, message: 'Destination would overwrite the live database. Choose a different directory.' };
|
||||
}
|
||||
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
// Online backup produces a consistent snapshot even while the DB is in use.
|
||||
await db.getDb().backup(path.join(dest, 'sencho.db'));
|
||||
|
||||
let keyNote = '';
|
||||
if (fs.existsSync(keyPath)) {
|
||||
fs.copyFileSync(keyPath, path.join(dest, 'encryption.key'));
|
||||
keyNote = ' + encryption.key';
|
||||
}
|
||||
|
||||
auditCli(db, '/cli/backup-data', `CLI backed up data directory to ${dest}`);
|
||||
return { ok: true, message: `Backup written to ${dest} (sencho.db${keyNote}). Store it somewhere safe; it contains your encryption key.` };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
exitWith(await backupData(process.argv[2]));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Emergency CLI: invalidate every active session by bumping every user's
|
||||
* token_version. Used after a suspected cookie theft or when a wedged login
|
||||
* state needs a clean sign-out of every user on this node.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/clearSessions.js
|
||||
*
|
||||
* Written to the audit log with actor `cli`.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
export function clearSessions(): CliResult {
|
||||
const db = DatabaseService.getInstance();
|
||||
const count = db.bumpAllTokenVersions();
|
||||
auditCli(db, '/cli/clear-sessions', `CLI cleared all sessions (${count} users)`);
|
||||
return { ok: true, message: `Cleared sessions for ${count} user(s). Everyone must sign in again.` };
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
exitWith(clearSessions());
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Emergency CLI: create a fresh local admin account from a shell inside the
|
||||
* container, used when every admin is locked out but the database is otherwise
|
||||
* intact (so resetting first-boot setup would needlessly discard config).
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/createEmergencyAdmin.js <username> <password>
|
||||
*
|
||||
* Refuses to overwrite an existing user; use resetPassword for that. Written to
|
||||
* the audit log with actor `cli`.
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { BCRYPT_SALT_ROUNDS } from '../helpers/constants';
|
||||
import { validateUsername } from '../helpers/validateUsername';
|
||||
import { auditCli, exitWith, usage, type CliResult } from './_shared';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export async function createEmergencyAdmin(username: string, password: string): Promise<CliResult> {
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) {
|
||||
return { ok: false, message: usernameError };
|
||||
}
|
||||
if (!password || password.length < MIN_PASSWORD_LENGTH) {
|
||||
return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` };
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getUserByUsername(username)) {
|
||||
return { ok: false, message: `User already exists: ${username}. Use reset-password instead.` };
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
db.addUser({ username, password_hash: passwordHash, role: 'admin', auth_provider: 'local' });
|
||||
auditCli(db, `/cli/create-emergency-admin/${username}`, `CLI created emergency admin ${username}`);
|
||||
return { ok: true, message: `Emergency admin ${username} created. Sign in and review your other accounts.` };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const username = process.argv[2];
|
||||
const password = process.argv[3];
|
||||
if (!username || !password) {
|
||||
usage('Usage: node dist/cli/createEmergencyAdmin.js <username> <password>');
|
||||
}
|
||||
exitWith(await createEmergencyAdmin(username, password));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Emergency CLI: print a redacted diagnostic summary (version, database
|
||||
* integrity, encryption-key status, admin/SSO/MFA counts, non-secret config) as
|
||||
* JSON. Read-only; carries no secrets (see DiagnosticsService for the
|
||||
* redaction allowlist). Safe to copy into a bug report.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/diagnostics.js
|
||||
*/
|
||||
import { collectDiagnostics } from '../services/DiagnosticsService';
|
||||
|
||||
export async function printDiagnostics(): Promise<void> {
|
||||
const report = await collectDiagnostics();
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await printDiagnostics();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(`Failed to collect diagnostics: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Emergency CLI: disable a broken SSO/OIDC/LDAP provider so local password
|
||||
* sign-in is reachable again. Used when a misconfigured identity provider
|
||||
* blocks the login screen.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/disableSso.js [provider]
|
||||
*
|
||||
* With no argument it disables every enabled provider. The stored configuration
|
||||
* is preserved (only the enabled flag is cleared) so it can be fixed and
|
||||
* re-enabled from the UI. Written to the audit log with actor `cli`.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
export function disableSso(provider?: string): CliResult {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
if (provider) {
|
||||
const config = db.getSSOConfig(provider);
|
||||
if (!config) {
|
||||
return { ok: false, message: `No SSO config found for provider: ${provider}` };
|
||||
}
|
||||
if (config.enabled !== 1) {
|
||||
return { ok: true, message: `SSO provider ${provider} is already disabled.` };
|
||||
}
|
||||
db.upsertSSOConfig(provider, false, config.config_json);
|
||||
auditCli(db, `/cli/disable-sso/${provider}`, `CLI disabled SSO provider ${provider}`);
|
||||
return { ok: true, message: `Disabled SSO provider ${provider}. Its configuration was preserved.` };
|
||||
}
|
||||
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
if (enabled.length === 0) {
|
||||
return { ok: true, message: 'No SSO providers are currently enabled.' };
|
||||
}
|
||||
for (const config of enabled) {
|
||||
db.upsertSSOConfig(config.provider, false, config.config_json);
|
||||
}
|
||||
const names = enabled.map(c => c.provider).join(', ');
|
||||
auditCli(db, '/cli/disable-sso', `CLI disabled all SSO providers (${enabled.length})`);
|
||||
return { ok: true, message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.` };
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
exitWith(disableSso(process.argv[2]));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Emergency CLI: reset a local user's password from a shell inside the
|
||||
* container, used when the admin password is forgotten and no other admin can
|
||||
* sign in to reset it from the UI.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/resetPassword.js <username> <new-password>
|
||||
*
|
||||
* Existing sessions for the target are invalidated by bumping `token_version`,
|
||||
* and the reset is written to the audit log with actor `cli`.
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { BCRYPT_SALT_ROUNDS } from '../helpers/constants';
|
||||
import { auditCli, exitWith, usage, type CliResult } from './_shared';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export async function resetPassword(username: string, newPassword: string): Promise<CliResult> {
|
||||
if (!username || typeof username !== 'string') {
|
||||
return { ok: false, message: 'Username is required' };
|
||||
}
|
||||
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` };
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
if (!user) {
|
||||
return { ok: false, message: `User not found: ${username}` };
|
||||
}
|
||||
if (user.auth_provider !== 'local') {
|
||||
return { ok: false, message: `User ${username} signs in via ${user.auth_provider}; password reset applies to local accounts only` };
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
|
||||
db.updateUser(user.id, { password_hash: passwordHash });
|
||||
db.bumpTokenVersion(user.id);
|
||||
auditCli(db, `/cli/reset-password/${username}`, `CLI reset password for ${username}`);
|
||||
return { ok: true, message: `Password reset for ${username}. Existing sessions were signed out.` };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const username = process.argv[2];
|
||||
const newPassword = process.argv[3];
|
||||
if (!username || !newPassword) {
|
||||
usage('Usage: node dist/cli/resetPassword.js <username> <new-password>');
|
||||
}
|
||||
exitWith(await resetPassword(username, newPassword));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Emergency CLI: validate that the database and encryption key are intact.
|
||||
* Runs a SQLite integrity check, confirms the core tables exist, confirms the
|
||||
* encryption key is present and usable, and confirms at least one admin exists.
|
||||
* Exits non-zero if any check fails, so it can gate a restore decision in a
|
||||
* script. Read-only.
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/validateDb.js
|
||||
*/
|
||||
import { collectDiagnostics } from '../services/DiagnosticsService';
|
||||
import { exitWith, type CliResult } from './_shared';
|
||||
|
||||
export async function validateDb(): Promise<CliResult> {
|
||||
const report = await collectDiagnostics();
|
||||
const problems: string[] = [];
|
||||
|
||||
if (!report.database.ok) {
|
||||
problems.push(`database integrity: ${report.database.integrity}`);
|
||||
if (report.database.missingTables.length > 0) {
|
||||
problems.push(`missing core tables: ${report.database.missingTables.join(', ')}`);
|
||||
}
|
||||
}
|
||||
if (!report.encryptionKey.present) {
|
||||
problems.push('encryption.key is missing');
|
||||
} else if (!report.encryptionKey.valid) {
|
||||
problems.push('encryption.key is present but invalid');
|
||||
}
|
||||
if (report.auth.adminCount === 0) {
|
||||
problems.push('no admin users exist');
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
return { ok: false, message: `Validation failed:\n - ${problems.join('\n - ')}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: `Database OK (integrity ok, ${report.auth.adminCount} admin(s), encryption key valid).`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
exitWith(await validateDb());
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user