fix(error-handling): surface silent errors across the codebase (#326)

Add console.warn/console.error logging to 22 silent catch blocks across
10 files. Errors in cleanup, migrations, SSO, fleet snapshots, shutdown,
and validation are now visible in logs. ENOENT guards added to
file-system catches to distinguish missing files from permission errors.
No control flow changes.
This commit is contained in:
Anso
2026-04-01 21:56:41 -04:00
committed by GitHub
parent eb9921e678
commit 10597d213a
11 changed files with 97 additions and 38 deletions
+1
View File
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
* **error-handling:** surface silent errors across the codebase — added `console.warn`/`console.error` logging to 22 silent catch blocks across 10 files (services, index.ts, frontend Login). Errors in cleanup, migrations, SSO, fleet snapshots, shutdown, and validation are now visible in logs without changing any control flow. ENOENT guards added to file-system catches to distinguish missing files from permission errors.
* **security:** self-heal encryption key file permissions on startup — verifies `0600` and corrects if permissive; Docker entrypoint also enforces `chmod 600` before privilege drop
* **security:** increase minimum password length from 6 to 8 characters (NIST SP 800-63B) — applies to setup, password change, and user management; existing short passwords remain valid until changed
* **security:** remove sensitive data from console output — file paths, `.env` locations, stack names, and admin usernames no longer logged to stdout
+35 -15
View File
@@ -517,7 +517,8 @@ app.get('/api/auth/sso/providers', (_req: Request, res: Response): void => {
try {
const providers = SSOService.getInstance().getEnabledProviders();
res.json(providers);
} catch {
} catch (e) {
console.warn('[SSO] Failed to list enabled providers, returning empty list:', (e as Error).message);
res.json([]);
}
});
@@ -621,7 +622,8 @@ app.get('/api/auth/sso/oidc/:provider/callback', async (req: Request, res: Respo
let statePayload: { state: string; codeVerifier: string; provider: string };
try {
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
} catch {
} catch (e) {
console.error('[SSO] Failed to decrypt SSO state cookie:', (e as Error).message);
res.redirect('/?sso_error=Invalid+SSO+session');
return;
}
@@ -1295,8 +1297,8 @@ async function captureLocalNodeFiles(node: Node): Promise<SnapshotNodeData> {
try {
const composeContent = await fsService.getStackContent(stackName);
files.push({ filename: 'compose.yaml', content: composeContent });
} catch {
// Stack has no compose file - skip
} catch (e) {
console.warn(`[Fleet Snapshot] Could not read compose file for stack "${stackName}", skipping:`, (e as Error).message);
continue;
}
try {
@@ -1339,7 +1341,8 @@ async function captureRemoteNodeFiles(node: Node): Promise<SnapshotNodeData> {
const content = await composeRes.text();
files.push({ filename: 'compose.yaml', content });
}
} catch {
} catch (e) {
console.warn(`[Fleet Snapshot] Failed to fetch remote compose for stack "${stackName}":`, (e as Error).message);
continue;
}
try {
@@ -1536,8 +1539,9 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response)
// Backup current files before restore
try {
await fsService.backupStackFiles(stackName);
} catch {
// Stack may not exist yet - that's ok
} catch (e) {
// Stack may not exist yet before first restore — that's ok
console.warn(`[Fleet Snapshot] Pre-restore backup failed for stack "${stackName}" (may not exist yet):`, (e as Error).message);
}
for (const file of files) {
@@ -2617,7 +2621,11 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
try {
await fsService.access(envPath);
} catch {
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.error('[Sencho] Unexpected error checking env file existence:', (e as Error).message);
}
return res.status(404).json({ error: 'Env file not found' });
}
@@ -3830,7 +3838,8 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
}
}
// Validate cron expression
try { CronExpressionParser.parse(cron_expression); } catch {
try { CronExpressionParser.parse(cron_expression); } catch (e) {
console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, (e as Error).message);
res.status(400).json({ error: 'Invalid cron expression.' }); return;
}
@@ -3945,7 +3954,8 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
}
if (cron_expression) {
try { CronExpressionParser.parse(cron_expression); } catch {
try { CronExpressionParser.parse(cron_expression); } catch (e) {
console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, (e as Error).message);
res.status(400).json({ error: 'Invalid cron expression.' }); return;
}
}
@@ -4733,11 +4743,21 @@ const gracefulShutdown = (signal: string) => {
server.close(() => {
console.log('[Shutdown] HTTP server closed');
try { LicenseService.getInstance().destroy(); } catch { /* already stopped */ }
try { MonitorService.getInstance().stop(); } catch { /* already stopped */ }
try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ }
try { SchedulerService.getInstance().stop(); } catch { /* already stopped */ }
try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ }
try { LicenseService.getInstance().destroy(); } catch (e) {
console.warn('[Shutdown] LicenseService cleanup failed:', (e as Error).message);
}
try { MonitorService.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] MonitorService cleanup failed:', (e as Error).message);
}
try { ImageUpdateService.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] ImageUpdateService cleanup failed:', (e as Error).message);
}
try { SchedulerService.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] SchedulerService cleanup failed:', (e as Error).message);
}
try { DatabaseService.getInstance().getDb().close(); } catch (e) {
console.warn('[Shutdown] Database close failed:', (e as Error).message);
}
console.log('[Shutdown] Done - exiting');
process.exit(0);
});
+4 -1
View File
@@ -99,7 +99,10 @@ export class ComposeService {
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
});
} finally {
try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch { /* best-effort cleanup */ }
try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch (e) {
// Best-effort cleanup: temp config dir may already be removed or locked
console.warn('[ComposeService] Could not clean up temp Docker config dir:', (e as Error).message);
}
}
}
+2 -1
View File
@@ -24,8 +24,9 @@ export class CryptoService {
console.warn(`[CryptoService] Fixing permissive key file permissions (was 0o${mode.toString(8)}, set to 0o600)`);
fs.chmodSync(keyPath, 0o600);
}
} catch {
} catch (e) {
// chmod not supported on this platform (e.g. Windows) — skip
console.warn('[CryptoService] Could not enforce key file permissions (platform may not support chmod):', (e as Error).message);
}
} else {
this.key = crypto.randomBytes(KEY_LENGTH);
+15 -3
View File
@@ -436,7 +436,12 @@ export class DatabaseService {
// Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
for (const col of legacyCols) {
try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch { /* already dropped or never existed */ }
try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch (e: unknown) {
// Expected: column already dropped or never existed
if (!String((e as Error)?.message).includes('no such column')) {
console.warn(`[DatabaseService] Unexpected error dropping legacy column "${col}":`, (e as Error).message);
}
}
}
// Initialize default global settings if they don't exist
@@ -511,7 +516,12 @@ export class DatabaseService {
private migrateSSOColumns(): void {
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch { /* already exists */ }
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e: unknown) {
// Expected: column already exists
if (!String((e as Error)?.message).includes('duplicate column')) {
console.warn(`[DatabaseService] Unexpected error adding column "${col}" to "${table}":`, (e as Error).message);
}
}
};
maybeAddCol('users', 'auth_provider', "TEXT NOT NULL DEFAULT 'local'");
maybeAddCol('users', 'provider_id', 'TEXT DEFAULT NULL');
@@ -562,7 +572,9 @@ export class DatabaseService {
`);
try {
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)');
} catch { /* already exists */ }
} catch (e) {
console.warn('[DatabaseService] Could not create role_assignments unique index:', (e as Error).message);
}
}
// --- Agents ---
+13 -7
View File
@@ -651,7 +651,10 @@ class DockerController {
// Destroy the Docker stats stream when the WebSocket closes to prevent
// orphaned streams polling the daemon after client disconnect.
ws.on('close', () => {
try { (stats as any).destroy(); } catch { /* stream already ended */ }
try { (stats as any).destroy(); } catch (e) {
// Stream already ended before client disconnected
console.warn('[DockerController] Stats stream already ended on WS close:', (e as Error).message);
}
});
}
@@ -723,8 +726,9 @@ class DockerController {
case 'resize':
if (msg.rows && msg.cols) {
exec.resize({ h: msg.rows, w: msg.cols }).catch(() => {
// Ignore resize errors (exec may have ended)
exec.resize({ h: msg.rows, w: msg.cols }).catch((e: Error) => {
// Exec may have ended before resize completes
console.warn('[DockerController] Exec resize failed (exec may have ended):', e.message);
});
}
break;
@@ -733,8 +737,9 @@ class DockerController {
// Keep-alive, no-op
break;
}
} catch {
// Non-JSON or malformed message - ignore
} catch (e) {
// Non-JSON or malformed WebSocket message
console.warn('[DockerController] Ignoring malformed exec WS message:', (e as Error).message);
}
});
@@ -742,8 +747,9 @@ class DockerController {
ws.on('close', () => {
try {
stream.destroy();
} catch {
// Ignore destroy errors
} catch (e) {
// Stream already destroyed before WS close
console.warn('[DockerController] Exec stream already destroyed on WS close:', (e as Error).message);
}
});
+17 -7
View File
@@ -275,8 +275,11 @@ export class FileSystemService {
try {
await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath);
} catch {
// No env file to migrate
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not migrate env file for ${stackName}:`, (e as Error).message);
}
}
}
@@ -300,8 +303,11 @@ export class FileSystemService {
try {
await fsPromises.access(src);
await fsPromises.copyFile(src, path.join(backupDir, file));
} catch {
// File doesn't exist, skip
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not back up ${file}:`, (e as Error).message);
}
}
}
@@ -310,8 +316,11 @@ export class FileSystemService {
try {
await fsPromises.access(envSrc);
await fsPromises.copyFile(envSrc, path.join(backupDir, '.env'));
} catch {
// No .env to backup
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.warn('[FileSystemService] Could not back up .env:', (e as Error).message);
}
}
// Write timestamp marker
@@ -343,7 +352,8 @@ export class FileSystemService {
try {
const ts = await fsPromises.readFile(tsFile, 'utf-8');
return { exists: true, timestamp: parseInt(ts, 10) || null };
} catch {
} catch (e) {
console.warn('[FileSystemService] Backup timestamp file unreadable:', (e as Error).message);
return { exists: true, timestamp: null };
}
} catch {
+2 -1
View File
@@ -7,7 +7,8 @@ function getUnixShell() {
try {
execSync('which bash', { stdio: 'ignore' });
return 'bash';
} catch {
} catch (e) {
console.warn('[HostTerminalService] bash not found, falling back to sh:', (e as Error).message);
return 'sh';
}
}
+3 -1
View File
@@ -180,7 +180,9 @@ export class MonitorService {
totalReclaimableBytes += bytes;
}
}
} catch (e) { }
} catch (e) {
console.warn('[MonitorService] Failed to parse Docker system df output:', e);
}
}
const reclaimGb = totalReclaimableBytes / (1024 * 1024 * 1024);
+2 -1
View File
@@ -18,7 +18,8 @@ export function isValidRemoteUrl(
let url: URL;
try {
url = new URL(raw);
} catch {
} catch (e) {
console.warn('[Validation] URL parse failure:', (e as Error).message, '— input:', raw);
return {
valid: false,
reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:3000)',
+3 -1
View File
@@ -63,7 +63,9 @@ export function Login({
fetch('/api/auth/sso/providers', { credentials: 'include' })
.then(r => r.ok ? r.json() : [])
.then((providers: SSOProvider[]) => setSsoProviders(providers))
.catch(() => {});
.catch((e) => {
console.warn('[Login] SSO provider discovery failed:', e);
});
}, []);
const hasLdap = ssoProviders.some(p => p.type === 'ldap');