The learning process was failing to communicate with the gateway because: 1. Gateway was sending 'Strict-Transport-Security' header on HTTP responses 2. Node.js fetch respects HSTS and upgrades subsequent requests to HTTPS 3. Gateway only has HTTP listener (localhost:3103), no HTTPS 4. Result: SSL 'packet length too long' error on second request attempt Solution: Modified registerHSTSMiddleware to only send HSTS header when the connection is already secure (HTTPS or x-forwarded-proto: https). HTTP connections will not get the HSTS header, preventing the forced upgrade.
205 lines
6.7 KiB
TypeScript
205 lines
6.7 KiB
TypeScript
import Fastify from 'fastify';
|
|
import fastifyCors from '@fastify/cors';
|
|
import fastifyRateLimit from '@fastify/rate-limit';
|
|
import fastifyHelmet from '@fastify/helmet';
|
|
import { completionRoute } from './routes/completion.js';
|
|
import { batchRoute } from './routes/batch.js';
|
|
import { classifyRoute } from './routes/classify.js';
|
|
import { healthRoute } from './routes/health.js';
|
|
import { metricsRoute } from './routes/metrics.js';
|
|
import { reviewRoute } from './routes/review.js';
|
|
import { dashboardRoute } from './routes/dashboard.js';
|
|
import { streamRoute } from './routes/stream.js';
|
|
import { learningInsightsRoute } from './routes/learning-insights.js';
|
|
import { staticRoute } from './routes/static.js';
|
|
import { getPool } from './db/client.js';
|
|
import { runMigrations } from './db/migrate.js';
|
|
import { initPgBoss } from './queue/pg-boss-client.js';
|
|
import { logger } from './observability/logger.js';
|
|
import { scheduleLearningCycles } from './learning/learning-engine.js';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join } from 'path';
|
|
import { readFileSync, existsSync } from 'fs';
|
|
import {
|
|
getTLSConfig,
|
|
loadTLSCertificates,
|
|
validateTLSConfig,
|
|
validateDatabaseSSL,
|
|
registerHSTSMiddleware,
|
|
registerHTTPSRedirectMiddleware,
|
|
registerSecurityHeadersMiddleware,
|
|
} from './security/tls-config.js';
|
|
|
|
const RATE_LIMITS: Record<string, number> = {
|
|
'n8n': 60,
|
|
'tip-scraper': 200,
|
|
'shieldx': 500,
|
|
'eo-global-pulse': 120,
|
|
'switchblade': 60,
|
|
'peercortex': 30,
|
|
'nognet': 30,
|
|
'dashboard': 300,
|
|
'internal': 1000,
|
|
'default': 100,
|
|
};
|
|
|
|
export function getCallerRateLimit(caller: string): number {
|
|
return RATE_LIMITS[caller] ?? RATE_LIMITS['default'] ?? 20;
|
|
}
|
|
|
|
async function buildServer() {
|
|
const server = Fastify({
|
|
logger: {
|
|
level: process.env['LOG_LEVEL'] ?? 'info',
|
|
},
|
|
trustProxy: true,
|
|
});
|
|
|
|
// CIS 3.2: Data in Transit Encryption
|
|
const tlsConfig = getTLSConfig();
|
|
const tlsErrors = validateTLSConfig(tlsConfig);
|
|
if (tlsErrors.length > 0) {
|
|
logger.warn({ tlsErrors }, 'TLS configuration warnings');
|
|
}
|
|
|
|
const dbSSLCheck = validateDatabaseSSL();
|
|
if (!dbSSLCheck.valid) {
|
|
logger.warn({ error: dbSSLCheck.error }, 'Database SSL validation failed');
|
|
}
|
|
|
|
// Register security headers middleware (before Helmet to allow proper ordering)
|
|
await registerSecurityHeadersMiddleware(server);
|
|
await registerHSTSMiddleware(server, tlsConfig);
|
|
await registerHTTPSRedirectMiddleware(server);
|
|
|
|
await server.register(fastifyHelmet, {
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
objectSrc: ["'none'"],
|
|
},
|
|
},
|
|
});
|
|
|
|
await server.register(fastifyCors, {
|
|
origin: [
|
|
'http://localhost:3000',
|
|
'http://localhost:3001',
|
|
'http://localhost:3100',
|
|
'http://192.168.178.169:3000',
|
|
'http://192.168.178.169:3001',
|
|
'http://192.168.178.196:3000',
|
|
/^http:\/\/192\.168\.178\.\d+/,
|
|
/^https:\/\/.*\.context-x\.org$/,
|
|
],
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Caller-ID'],
|
|
credentials: true,
|
|
});
|
|
|
|
await server.register(fastifyRateLimit, {
|
|
global: true,
|
|
max: 100,
|
|
timeWindow: '1 minute',
|
|
keyGenerator: (request) => {
|
|
const caller = (request.headers['x-caller-id'] as string) ?? 'default';
|
|
return `${caller}:${request.ip}`;
|
|
},
|
|
errorResponseBuilder: (_request, context) => ({
|
|
statusCode: 429,
|
|
error: 'Too Many Requests',
|
|
message: `Rate limit exceeded. Try again in ${context.after}`,
|
|
}),
|
|
});
|
|
|
|
await server.register(completionRoute, { prefix: '/v1' });
|
|
await server.register(batchRoute, { prefix: '/v1' });
|
|
await server.register(classifyRoute, { prefix: '/v1' });
|
|
await server.register(reviewRoute, { prefix: '/v1' });
|
|
await server.register(learningInsightsRoute, { prefix: '/v1' });
|
|
await server.register(healthRoute);
|
|
await server.register(metricsRoute);
|
|
await server.register(staticRoute);
|
|
await server.register(dashboardRoute);
|
|
await server.register(streamRoute);
|
|
|
|
server.setErrorHandler((error, request, reply) => {
|
|
logger.error({ error, url: request.url, method: request.method }, 'Unhandled error');
|
|
const statusCode = (error instanceof Error && 'statusCode' in error && typeof (error as any).statusCode === 'number') ? (error as any).statusCode : 500;
|
|
const errorName = error instanceof Error ? error.name : 'InternalServerError';
|
|
const errorMessage = error instanceof Error ? error.message : 'Internal server error';
|
|
reply.status(statusCode).send({
|
|
statusCode,
|
|
error: errorName,
|
|
message: statusCode >= 500 ? 'Internal server error' : errorMessage,
|
|
});
|
|
});
|
|
|
|
server.setNotFoundHandler((request, reply) => {
|
|
// Serve dashboard for root path as fallback (handles Cloudflare tunnel routing issues)
|
|
if (request.url === '/' || request.url === '/dashboard.html') {
|
|
try {
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const publicDir = join(__dirname, '..', 'public');
|
|
const dashboardPath = join(publicDir, 'dashboard.html');
|
|
if (existsSync(dashboardPath)) {
|
|
const content = readFileSync(dashboardPath, 'utf-8');
|
|
return reply.type('text/html').send(content);
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to serve dashboard fallback');
|
|
}
|
|
}
|
|
reply.status(404).send({ statusCode: 404, error: 'Not Found', message: 'Route not found' });
|
|
});
|
|
|
|
return server;
|
|
}
|
|
|
|
async function main() {
|
|
const server = await buildServer();
|
|
|
|
const shutdown = async (signal: string) => {
|
|
logger.info({ signal }, 'Shutdown signal received');
|
|
try {
|
|
await server.close();
|
|
const pool = getPool();
|
|
await pool.end();
|
|
logger.info('Server and DB connections closed');
|
|
process.exit(0);
|
|
} catch (err) {
|
|
logger.error({ err }, 'Error during shutdown');
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
|
const port = parseInt(process.env['PORT'] ?? '3100', 10);
|
|
const host = process.env['HOST'] ?? '0.0.0.0';
|
|
|
|
try {
|
|
try {
|
|
await runMigrations();
|
|
} catch (migErr) {
|
|
logger.warn({ migErr }, 'Migration failed - starting server without DB');
|
|
}
|
|
try {
|
|
await initPgBoss();
|
|
} catch (pgErr) {
|
|
logger.warn({ pgErr }, 'PgBoss init failed - continuing without queue');
|
|
}
|
|
scheduleLearningCycles();
|
|
await server.listen({ port, host });
|
|
logger.info({ port, host }, 'LLM Gateway started');
|
|
} catch (err) {
|
|
logger.error({ err }, 'Failed to start server');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|