Rene Fichtmueller 1d4be52c83 fix: only send HSTS header on HTTPS connections, not HTTP
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.
2026-04-26 19:01:41 +02:00

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();