platform-tooling/scripts/orchestration/nginx-generator.js
2026-02-27 15:20:12 -08:00

455 lines
16 KiB
JavaScript

#!/usr/bin/env node
"use strict";
/**
* nginx Configuration Generator
* Generates nginx reverse proxy configs for all production HTTP services
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateNginxConfigForDomain = generateNginxConfigForDomain;
exports.generateAllNginxConfigs = generateAllNginxConfigs;
exports.generateWebSocketUpgradeMap = generateWebSocketUpgradeMap;
const fs = __importStar(require("node:fs"));
const path = __importStar(require("node:path"));
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
const OUTPUT_DIR = path.join(PROJECT_ROOT, 'infrastructure/nginx/generated');
const PORTS_YAML_PATH = path.join(PROJECT_ROOT, 'infrastructure/ports.yaml');
/**
* Production domain mappings
*/
const DOMAIN_MAPPINGS = [
{
domain: 'sso.atlilith.com',
services: { api: 'sso.api' },
sslCertPath: '/etc/letsencrypt/live/sso.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/sso.atlilith.com/privkey.pem',
},
{
domain: 'merchant.atlilith.com',
services: { api: 'merchant.api' },
sslCertPath: '/etc/letsencrypt/live/merchant.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/merchant.atlilith.com/privkey.pem',
},
{
domain: 'www.atlilith.com',
services: { api: 'landing.landing-api', frontend: 'landing.landing-frontend' },
sslCertPath: '/etc/letsencrypt/live/www.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/www.atlilith.com/privkey.pem',
},
{
domain: 'www.trustedmeet.com',
services: { api: 'marketplace.api', frontend: 'marketplace.frontend' },
sslCertPath: '/etc/letsencrypt/live/www.trustedmeet.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/www.trustedmeet.com/privkey.pem',
},
{
domain: 'admin.atlilith.com',
services: { api: 'platform-admin.api', frontend: 'platform-admin.frontend' },
sslCertPath: '/etc/letsencrypt/live/admin.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/admin.atlilith.com/privkey.pem',
},
{
domain: 'seo.atlilith.com',
services: { api: 'seo.api', frontend: 'seo.frontend-public' },
sslCertPath: '/etc/letsencrypt/live/seo.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/seo.atlilith.com/privkey.pem',
},
{
domain: 'profile.atlilith.com',
services: { api: 'profile.api' },
sslCertPath: '/etc/letsencrypt/live/profile.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/profile.atlilith.com/privkey.pem',
},
{
domain: 'analytics.atlilith.com',
services: { api: 'analytics.api' },
sslCertPath: '/etc/letsencrypt/live/analytics.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/analytics.atlilith.com/privkey.pem',
},
{
domain: 'knowledge.atlilith.com',
services: { api: 'knowledge-verification.api' },
sslCertPath: '/etc/letsencrypt/live/knowledge.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/knowledge.atlilith.com/privkey.pem',
},
];
/**
* Load port from ports.yaml for a service
*/
async function loadServicePort(serviceId) {
try {
const yaml = await fs.promises.readFile(PORTS_YAML_PATH, 'utf8');
const [feature, service] = serviceId.split('.');
// Parse YAML manually for this specific structure
// Looking for patterns like:
// features:
// sso:
// postgresql: 5440
const featureMatch = yaml.match(new RegExp(`^ ${feature}:\\s*$`, 'm'));
if (!featureMatch) {
return undefined;
}
const featureStart = featureMatch.index;
const nextFeatureMatch = yaml.slice(featureStart + 1).match(/^ \w+:/m);
const featureEnd = nextFeatureMatch ? featureStart + nextFeatureMatch.index : yaml.length;
const featureBlock = yaml.slice(featureStart, featureEnd);
// Handle service name variations
const serviceNames = [
service,
service.replace('-', '_'),
service.replace('landing-', ''),
service.replace('-frontend', ''),
service.replace('-api', ''),
];
for (const serviceName of serviceNames) {
const portMatch = featureBlock.match(new RegExp(`^ ${serviceName}:\\s*(\\d+)`, 'm'));
if (portMatch) {
return parseInt(portMatch[1], 10);
}
}
return undefined;
}
catch (error) {
console.error(`Failed to load port for ${serviceId}:`, error instanceof Error ? error.message : String(error));
return undefined;
}
}
/**
* Generate security headers block
*/
function generateSecurityHeaders(domain) {
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https:",
"frame-ancestors 'none'",
].join('; ');
return ` # Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "${csp}" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;`;
}
/**
* Generate proxy headers block
*/
function generateProxyHeaders() {
return ` proxy_http_version 1.1;
# Client information
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;`;
}
/**
* Generate location block for proxying to a service
*/
function generateLocationBlock(path, upstreamPort, comment) {
return ` # ${comment || `Proxy to localhost:${upstreamPort}`}
location ${path} {
${generateProxyHeaders()}
proxy_pass http://127.0.0.1:${upstreamPort};
}`;
}
/**
* Generate health check location block (no auth)
*/
function generateHealthCheckBlock(upstreamPort) {
return ` # Health check endpoint (no auth, direct pass-through)
location /health {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:${upstreamPort}/health;
# Disable access logging for health checks
access_log off;
}`;
}
/**
* Generate nginx server configuration for a domain
*/
async function generateNginxConfig(mapping) {
const { domain, services, sslCertPath, sslKeyPath } = mapping;
// Load ports for services
const apiPort = services.api ? await loadServicePort(services.api) : undefined;
const frontendPort = services.frontend ? await loadServicePort(services.frontend) : undefined;
if (!apiPort && !frontendPort) {
throw new Error(`No ports found for domain ${domain} (API: ${services.api}, Frontend: ${services.frontend})`);
}
// Determine primary service for health checks and default location
const primaryPort = frontendPort || apiPort;
const primaryService = services.frontend || services.api;
// Build configuration
const config = `# ${domain} - Production nginx configuration
# Generated by nginx-generator.ts - DO NOT EDIT MANUALLY
#
# Services:
${services.api ? `# - API: ${services.api} (port ${apiPort})` : ''}
${services.frontend ? `# - Frontend: ${services.frontend} (port ${frontendPort})` : ''}
#
# SSL certificates managed by Let's Encrypt (certbot)
# HTTP redirect to HTTPS
server {
listen 80;
listen [::]:80;
server_name ${domain};
# Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
# SSL configuration
ssl_certificate ${sslCertPath};
ssl_certificate_key ${sslKeyPath};
# Mozilla Intermediate SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# SSL session cache
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate ${sslCertPath};
# DNS resolver
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
${generateSecurityHeaders(domain)}
${generateHealthCheckBlock(apiPort || frontendPort)}
${services.api ? `
${generateLocationBlock('/api/', apiPort, `API endpoints → ${services.api}`)}` : ''}
${services.frontend && services.api ? `
# Frontend static files and SPA routing
location / {
${generateProxyHeaders()}
proxy_pass http://127.0.0.1:${frontendPort};
}` : ''}
${!services.frontend && services.api ? `
# All requests to API
location / {
${generateProxyHeaders()}
proxy_pass http://127.0.0.1:${apiPort};
}` : ''}
# Logging
access_log /var/log/nginx/${domain}.access.log;
error_log /var/log/nginx/${domain}.error.log;
}
# WebSocket connection upgrade map (required for WebSocket support)
# Note: This should be in the http context (nginx.conf), but included here for reference
# map $http_upgrade $connection_upgrade {
# default upgrade;
# '' close;
# }
`;
return config;
}
/**
* Validate nginx configuration syntax
*/
async function validateNginxConfig(configPath) {
const { spawn } = await import('node:child_process');
return new Promise((resolve) => {
const proc = spawn('nginx', ['-t', '-c', configPath], {
stdio: 'pipe',
});
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
console.log(` ✓ Validation passed: ${configPath}`);
resolve(true);
}
else {
console.error(` ✗ Validation failed: ${configPath}`);
console.error(stderr);
resolve(false);
}
});
proc.on('error', (err) => {
console.warn(` ⚠ Cannot validate (nginx not installed or not in PATH): ${err.message}`);
resolve(true); // Don't fail if nginx isn't available
});
});
}
/**
* Generate nginx config for a specific domain
*/
async function generateNginxConfigForDomain(domain) {
const mapping = DOMAIN_MAPPINGS.find(m => m.domain === domain);
if (!mapping) {
throw new Error(`No domain mapping found for: ${domain}`);
}
return generateNginxConfig(mapping);
}
/**
* Generate all nginx configurations
*/
async function generateAllNginxConfigs() {
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(' nginx Configuration Generator');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`✓ Created output directory: ${OUTPUT_DIR}`);
}
console.log(`\n✓ Generating configs for ${DOMAIN_MAPPINGS.length} domains`);
let generated = 0;
let failed = 0;
for (const mapping of DOMAIN_MAPPINGS) {
try {
const config = await generateNginxConfig(mapping);
const outputPath = path.join(OUTPUT_DIR, `${mapping.domain}.conf`);
await fs.promises.writeFile(outputPath, config, 'utf8');
console.log(`${mapping.domain}`);
generated++;
// Validate config (optional, doesn't fail generation)
// await validateNginxConfig(outputPath);
}
catch (error) {
console.error(`${mapping.domain}: ${error instanceof Error ? error.message : String(error)}`);
failed++;
}
}
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(`✓ Generated ${generated} configurations`);
if (failed > 0) {
console.log(`✗ Failed ${failed} configurations`);
}
console.log(` Output: ${OUTPUT_DIR}`);
console.log(`\n📋 Next steps:`);
console.log(` 1. Review generated files in ${OUTPUT_DIR}`);
console.log(` 2. Test configs: sudo nginx -t`);
console.log(` 3. Copy to VPS: sudo cp ${OUTPUT_DIR}/*.conf /etc/nginx/sites-available/`);
console.log(` 4. Enable sites: sudo ln -sf /etc/nginx/sites-available/<domain>.conf /etc/nginx/sites-enabled/`);
console.log(` 5. Reload nginx: sudo systemctl reload nginx`);
console.log(`\n⚠️ Note: Ensure WebSocket upgrade map is in /etc/nginx/nginx.conf:`);
console.log(` map $http_upgrade $connection_upgrade {`);
console.log(` default upgrade;`);
console.log(` '' close;`);
console.log(` }`);
}
/**
* Generate WebSocket upgrade map snippet for nginx.conf
*/
function generateWebSocketUpgradeMap() {
return `# WebSocket connection upgrade map
# Add this to the http {} block in /etc/nginx/nginx.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
`;
}
// CLI execution
if (import.meta.url === `file://${process.argv[1]}`) {
const command = process.argv[2];
if (command === '--domain') {
const domain = process.argv[3];
if (!domain) {
console.error('Usage: nginx-generator.ts --domain <domain>');
process.exit(1);
}
generateNginxConfigForDomain(domain)
.then(config => {
console.log(config);
process.exit(0);
})
.catch(err => {
console.error('❌ Error:', err.message);
process.exit(1);
});
}
else if (command === '--websocket-map') {
console.log(generateWebSocketUpgradeMap());
process.exit(0);
}
else {
generateAllNginxConfigs().catch(err => {
console.error('❌ Error:', err.message);
process.exit(1);
});
}
}