diff --git a/server.js b/server.js index eb4b29e..3612f41 100644 --- a/server.js +++ b/server.js @@ -8,12 +8,15 @@ import stripAnsi from 'strip-ansi'; import { spawn as ptySpawn } from 'node-pty'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getDatabase } from './src/server/database-prisma.js'; +import { registerGlobalErrorHandlers } from './src/server/logging/globalHandlers.js'; const dev = process.env.NODE_ENV !== 'production'; const hostname = '0.0.0.0'; const port = parseInt(process.env.PORT || '3000', 10); const app = next({ dev, hostname, port }); +// Register global handlers once at bootstrap +registerGlobalErrorHandlers(); const handle = app.getRequestHandler(); // WebSocket handler for script execution diff --git a/src/app/api/servers/[id]/route.ts b/src/app/api/servers/[id]/route.ts index 0026b0c..2ca4bf1 100644 --- a/src/app/api/servers/[id]/route.ts +++ b/src/app/api/servers/[id]/route.ts @@ -2,8 +2,9 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getDatabase } from '../../../../server/database-prisma'; import type { CreateServerData } from '../../../../types/server'; +import { withApiLogging } from '../../../../server/logging/withApiLogging'; -export async function GET( +export const GET = withApiLogging(async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { @@ -28,16 +29,16 @@ export async function GET( } return NextResponse.json(server); - } catch (error) { - console.error('Error fetching server:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to fetch server' }, { status: 500 } ); } -} +}, { redactBody: true }); -export async function PUT( +export const PUT = withApiLogging(async function PUT( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { @@ -62,8 +63,9 @@ export async function PUT( ); } - // Validate SSH port - if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) { + // Coerce and validate SSH port + const port = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22; + if (Number.isNaN(port) || port < 1 || port > 65535) { return NextResponse.json( { error: 'SSH port must be between 1 and 65535' }, { status: 400 } @@ -111,7 +113,7 @@ export async function PUT( auth_type: authType, ssh_key, ssh_key_passphrase, - ssh_port: ssh_port ?? 22, + ssh_port: port, color, key_generated: key_generated ?? false, ssh_key_path @@ -124,7 +126,7 @@ export async function PUT( } ); } catch (error) { - console.error('Error updating server:', error); + // Error handled by withApiLogging // Handle unique constraint violation if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { @@ -139,9 +141,9 @@ export async function PUT( { status: 500 } ); } -} +}, { redactBody: true }); -export async function DELETE( +export const DELETE = withApiLogging(async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { @@ -177,12 +179,12 @@ export async function DELETE( changes: 1 } ); - } catch (error) { - console.error('Error deleting server:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to delete server' }, { status: 500 } ); } -} +}, { redactBody: true }); diff --git a/src/app/api/servers/generate-keypair/route.ts b/src/app/api/servers/generate-keypair/route.ts index 69a6986..d9a3fb4 100644 --- a/src/app/api/servers/generate-keypair/route.ts +++ b/src/app/api/servers/generate-keypair/route.ts @@ -2,8 +2,9 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getSSHService } from '../../../../server/ssh-service'; import { getDatabase } from '../../../../server/database-prisma'; +import { withApiLogging } from '../../../../server/logging/withApiLogging'; -export async function POST(_request: NextRequest) { +export const POST = withApiLogging(async function POST(_request: NextRequest) { try { const sshService = getSSHService(); const db = getDatabase(); @@ -20,7 +21,7 @@ export async function POST(_request: NextRequest) { serverId: serverId }); } catch (error) { - console.error('Error generating SSH key pair:', error); + // Error handled by withApiLogging return NextResponse.json( { success: false, @@ -29,4 +30,4 @@ export async function POST(_request: NextRequest) { { status: 500 } ); } -} +}, { redactBody: true }); diff --git a/src/app/api/servers/route.ts b/src/app/api/servers/route.ts index e8b6b1b..89285fa 100644 --- a/src/app/api/servers/route.ts +++ b/src/app/api/servers/route.ts @@ -2,22 +2,23 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getDatabase } from '../../../server/database-prisma'; import type { CreateServerData } from '../../../types/server'; +import { withApiLogging } from '../../../server/logging/withApiLogging'; -export async function GET() { +export const GET = withApiLogging(async function GET() { try { const db = getDatabase(); const servers = await db.getAllServers(); return NextResponse.json(servers); - } catch (error) { - console.error('Error fetching servers:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to fetch servers' }, { status: 500 } ); } -} +}, { redactBody: true }); -export async function POST(request: NextRequest) { +export const POST = withApiLogging(async function POST(request: NextRequest) { try { const body = await request.json(); const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body; @@ -30,8 +31,9 @@ export async function POST(request: NextRequest) { ); } - // Validate SSH port - if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) { + // Coerce and validate SSH port + const port = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22; + if (Number.isNaN(port) || port < 1 || port > 65535) { return NextResponse.json( { error: 'SSH port must be between 1 and 65535' }, { status: 400 } @@ -69,7 +71,7 @@ export async function POST(request: NextRequest) { auth_type: authType, ssh_key, ssh_key_passphrase, - ssh_port: ssh_port ?? 22, + ssh_port: port, color, key_generated: key_generated ?? false, ssh_key_path @@ -82,11 +84,10 @@ export async function POST(request: NextRequest) { }, { status: 201 } ); - } catch (error) { - console.error('Error creating server:', error); - + } catch { + // Error handled by withApiLogging // Handle unique constraint violation - if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { + if (Error instanceof Error && Error.message.includes('UNIQUE constraint failed')) { return NextResponse.json( { error: 'A server with this name already exists' }, { status: 409 } @@ -98,5 +99,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } -} +}, { redactBody: true }); diff --git a/src/app/api/settings/auth-credentials/route.ts b/src/app/api/settings/auth-credentials/route.ts index 8c88566..dbc6836 100644 --- a/src/app/api/settings/auth-credentials/route.ts +++ b/src/app/api/settings/auth-credentials/route.ts @@ -3,8 +3,9 @@ import { NextResponse } from 'next/server'; import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth'; import fs from 'fs'; import path from 'path'; +import { withApiLogging } from '../../../../server/logging/withApiLogging'; -export async function GET() { +export const GET = withApiLogging(async function GET() { try { const authConfig = getAuthConfig(); @@ -14,16 +15,16 @@ export async function GET() { hasCredentials: authConfig.hasCredentials, setupCompleted: authConfig.setupCompleted, }); - } catch (error) { - console.error('Error reading auth credentials:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to read auth configuration' }, { status: 500 } ); } -} +}, { redactBody: true }); -export async function POST(request: NextRequest) { +export const POST = withApiLogging(async function POST(request: NextRequest) { try { const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean }; @@ -54,16 +55,16 @@ export async function POST(request: NextRequest) { success: true, message: 'Authentication credentials updated successfully' }); - } catch (error) { - console.error('Error updating auth credentials:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to update auth credentials' }, { status: 500 } ); } -} +}, { redactBody: true }); -export async function PATCH(request: NextRequest) { +export const PATCH = withApiLogging(async function PATCH(request: NextRequest) { try { const { enabled } = await request.json() as { enabled: boolean }; @@ -107,11 +108,11 @@ export async function PATCH(request: NextRequest) { success: true, message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully` }); - } catch (error) { - console.error('Error updating auth enabled status:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to update auth status' }, { status: 500 } ); } -} +}, { redactBody: true }); diff --git a/src/app/api/settings/github-token/route.ts b/src/app/api/settings/github-token/route.ts index 98598c6..b4e3739 100644 --- a/src/app/api/settings/github-token/route.ts +++ b/src/app/api/settings/github-token/route.ts @@ -2,8 +2,9 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; +import { withApiLogging } from '../../../../server/logging/withApiLogging'; -export async function POST(request: NextRequest) { +export const POST = withApiLogging(async function POST(request: NextRequest) { try { const { token } = await request.json(); @@ -39,16 +40,16 @@ export async function POST(request: NextRequest) { fs.writeFileSync(envPath, envContent); return NextResponse.json({ success: true, message: 'GitHub token saved successfully' }); - } catch (error) { - console.error('Error saving GitHub token:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to save GitHub token' }, { status: 500 } ); } -} +}, { redactBody: true }); -export async function GET() { +export const GET = withApiLogging(async function GET() { try { // Path to the .env file const envPath = path.join(process.cwd(), '.env'); @@ -65,11 +66,11 @@ export async function GET() { const token = githubTokenMatch ? githubTokenMatch[1] : null; return NextResponse.json({ token }); - } catch (error) { - console.error('Error reading GitHub token:', error); + } catch { + // Error handled by withApiLogging return NextResponse.json( { error: 'Failed to read GitHub token' }, { status: 500 } ); } -} +}, { redactBody: true }); diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index 7514490..8385ee4 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -4,6 +4,7 @@ import { type NextRequest } from "next/server"; import { env } from "~/env.js"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; +import logger from "../../../../server/logging/logger"; const handler = (req: NextRequest) => fetchRequestHandler({ @@ -14,9 +15,7 @@ const handler = (req: NextRequest) => onError: env.NODE_ENV === "development" ? ({ path, error }) => { - console.error( - `[ERROR] tRPC failed on ${path ?? ""}: ${error.message}`, - ); + logger.error("trpc_error", { path: path ?? "" }, error); } : undefined, }); diff --git a/src/server/db.ts b/src/server/db.ts index e2b9605..aa275ce 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -4,6 +4,8 @@ const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; -export const prisma = globalForPrisma.prisma ?? new PrismaClient(); +export const prisma = globalForPrisma.prisma ?? new PrismaClient({ + log: ['warn', 'error'] +}); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; diff --git a/src/server/logging/globalHandlers.ts b/src/server/logging/globalHandlers.ts new file mode 100644 index 0000000..1e982e4 --- /dev/null +++ b/src/server/logging/globalHandlers.ts @@ -0,0 +1,21 @@ +import logger from './logger'; +import { toSafeError } from './prismaSafeError'; + +let registered = false; + +export function registerGlobalErrorHandlers() { + if (registered) return; + registered = true; + + process.on('uncaughtException', (err) => { + const safe = toSafeError(err); + logger.error('uncaught_exception', { name: safe.name, code: safe.code }, err); + }); + + process.on('unhandledRejection', (reason) => { + const safe = toSafeError(reason as any); + logger.error('unhandled_rejection', { name: safe.name, code: safe.code }, reason); + }); +} + + diff --git a/src/server/logging/logger.ts b/src/server/logging/logger.ts new file mode 100644 index 0000000..5e8ee1b --- /dev/null +++ b/src/server/logging/logger.ts @@ -0,0 +1,81 @@ +import { redactObject } from './redact'; + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVELS: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +const envLevel = (process.env.LOG_LEVEL as LogLevel) || 'info'; +const currentLevel = LOG_LEVELS[envLevel] ?? LOG_LEVELS.info; + +function safeMeta(meta?: unknown): unknown { + if (meta === undefined) return undefined; + try { + return redactObject(meta); + } catch { + return undefined; + } +} + +function safeError(err: unknown): { name?: string; code?: string; stack?: string } | undefined { + if (!err) return undefined; + if (err instanceof Error) { + return { + name: err.name, + code: (err as any).code, + stack: formatStack(err.stack) + }; + } + return undefined; +} + +function formatStack(stack?: string): string | undefined { + if (!stack) return undefined; + const lines = stack.split('\n').slice(0, 10); + return lines.join('\n'); +} + +function log(level: LogLevel, message: string, meta?: unknown, err?: unknown) { + if (LOG_LEVELS[level] < currentLevel) return; + const payload: Record = { + level, + msg: message, + time: new Date().toISOString(), + }; + const redactedMeta = safeMeta(meta); + if (redactedMeta !== undefined) payload.meta = redactedMeta; + const safeErr = safeError(err); + if (safeErr) payload.err = safeErr; + + const line = JSON.stringify(payload); + if (level === 'error') { + console.error(line); + } else if (level === 'warn') { + console.warn(line); + } else { + console.log(line); + } +} + +export const logger = { + debug(message: string, meta?: unknown) { + log('debug', message, meta); + }, + info(message: string, meta?: unknown) { + log('info', message, meta); + }, + warn(message: string, meta?: unknown) { + log('warn', message, meta); + }, + error(message: string, meta?: unknown, err?: unknown) { + log('error', message, meta, err); + } +}; + +export default logger; + + diff --git a/src/server/logging/prismaSafeError.ts b/src/server/logging/prismaSafeError.ts new file mode 100644 index 0000000..beb9a72 --- /dev/null +++ b/src/server/logging/prismaSafeError.ts @@ -0,0 +1,36 @@ +type SafeError = { code?: string; name: string; safeMessage: string }; + +export function toSafeError(err: unknown): SafeError { + if (err && typeof err === 'object') { + const name = (err as any).name as string | undefined; + const code = (err as any).code as string | undefined; + + // Prisma error names to map + if (name === 'PrismaClientValidationError') { + return { name, code, safeMessage: 'Invalid input' }; + } + if (name === 'PrismaClientKnownRequestError') { + // Avoid echoing message which may include parameters + return { name, code, safeMessage: 'Database constraint or known request error' }; + } + if (name === 'PrismaClientUnknownRequestError') { + return { name, code, safeMessage: 'Database request failed' }; + } + if (name === 'PrismaClientRustPanicError') { + return { name, code, safeMessage: 'Database engine error' }; + } + if (name === 'PrismaClientInitializationError') { + return { name, code, safeMessage: 'Database initialization failed' }; + } + if (name === 'PrismaClientFetchEngineError') { + return { name, code, safeMessage: 'Database engine fetch error' }; + } + + if (name) { + return { name, code, safeMessage: 'Unhandled server error' }; + } + } + return { name: 'Error', safeMessage: 'Unhandled server error' }; +} + + diff --git a/src/server/logging/redact.ts b/src/server/logging/redact.ts new file mode 100644 index 0000000..5338de1 --- /dev/null +++ b/src/server/logging/redact.ts @@ -0,0 +1,86 @@ +const DEFAULT_MASK = '***REDACTED***'; + +const DEFAULT_SENSITIVE_KEYS = [ + 'password', + 'ssh_key', + 'sshKey', + 'ssh_key_passphrase', + 'sshKeyPassphrase', + 'token', + 'access_token', + 'refresh_token', + 'authorization', + 'cookie', + 'set-cookie', + 'secret', + 'apiKey', + 'apikey' +]; + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +export function redactObject( + value: unknown, + opts?: { keys?: string[]; mask?: string } +): unknown { + const keys = (opts?.keys ?? DEFAULT_SENSITIVE_KEYS).map(k => k.toLowerCase()); + const mask = opts?.mask ?? DEFAULT_MASK; + + const visit = (val: unknown): unknown => { + if (val === null || val === undefined) return val; + if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') return val; + if (Array.isArray(val)) return val.map(visit); + if (val instanceof Map) { + const mapped = new Map(); + for (const [k, v] of val.entries()) { + const shouldRedact = typeof k === 'string' && keys.includes(k.toLowerCase()); + mapped.set(k, shouldRedact ? mask : visit(v)); + } + return mapped; + } + if (isPlainObject(val)) { + const out: Record = {}; + for (const [k, v] of Object.entries(val)) { + const shouldRedact = keys.includes(k.toLowerCase()); + out[k] = shouldRedact ? mask : visit(v); + } + return out; + } + return maskIfLikelySecret(val, mask); + }; + + return visit(value); +} + +function maskIfLikelySecret(val: unknown, mask: string): unknown { + // For safety, non-serializable or unexpected values get masked when logged + try { + JSON.stringify(val); + return val; + } catch { + return mask; + } +} + +export function summarizeBody(body: unknown): { keys: string[]; size?: number } { + try { + if (isPlainObject(body)) { + const keys = Object.keys(body); + const size = Buffer.from(JSON.stringify(body)).length; + return { keys, size }; + } + if (Array.isArray(body)) { + const size = Buffer.from(JSON.stringify(body)).length; + return { keys: [''], size }; + } + return { keys: [typeof body], size: undefined }; + } catch { + return { keys: [''] }; + } +} + +export const SENSITIVE_KEYS = DEFAULT_SENSITIVE_KEYS; + + diff --git a/src/server/logging/withApiLogging.ts b/src/server/logging/withApiLogging.ts new file mode 100644 index 0000000..a2d3765 --- /dev/null +++ b/src/server/logging/withApiLogging.ts @@ -0,0 +1,48 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import logger from './logger'; +import { redactObject, summarizeBody } from './redact'; +import { toSafeError } from './prismaSafeError'; + +type Handler = (request: NextRequest, context?: any) => Promise | Response; + +export function withApiLogging( + handler: Handler, + opts?: { redactBody?: boolean } +) { + const { redactBody = false } = opts ?? {}; + + return async function wrapped(request: NextRequest, context?: any) { + const url = new URL(request.url); + const method = request.method; + const path = url.pathname; + const queryKeys = Array.from(url.searchParams.keys()); + + try { + let meta: Record = { method, path, queryKeys }; + if (method !== 'GET' && method !== 'HEAD') { + try { + const body = await request.clone().json(); + meta = { + ...meta, + body: redactBody ? undefined : redactObject(body), + bodySummary: redactBody ? summarizeBody(body) : undefined + }; + } catch { + // Ignore non-JSON bodies + } + } + logger.info('api_request', meta); + + const response = await handler(request, context); + logger.info('api_response', { method, path, status: response.status }); + return response; + } catch (err) { + const safe = toSafeError(err); + logger.error('api_error', { method, path, code: safe.code, name: safe.name }, err); + return NextResponse.json({ error: safe.safeMessage }, { status: 500 }); + } + }; +} + +