platform-tooling/scripts/orchestration/nginx-generator.ts
Quinn Ftw e71bcce08b chore(cli): 🔧 Update CLI scripts in index.ts files (including one additional file)
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-22 08:00:47 -08:00

510 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* nginx Configuration Generator
* Generates nginx reverse proxy configs for all production HTTP services
*
* Uses deployment-centric configuration from:
* - deployments/@domains/{deployment}/services.yaml
* - deployments/shared-services/{service}.yaml
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parse as parseYaml } from 'yaml';
import { PATHS } from '../../configs/paths';
const OUTPUT_DIR = PATHS.nginxGenerated;
const DEPLOYMENTS_PATH = PATHS.domains;
const SHARED_SERVICES_PATH = PATHS.sharedServices;
/**
* Service definition from deployment services.yaml
*/
interface ServiceDef {
id: string;
type: string;
port: number;
healthCheck?: {
type: string;
path?: string;
};
}
/**
* Deployment configuration from services.yaml
*/
interface DeploymentConfig {
deployment: {
id: string;
name: string;
type?: 'shared' | 'isolated';
domain?: string;
};
services: ServiceDef[];
deployments?: {
production?: {
host: string;
domain?: string;
};
};
}
/**
* Domain mapping configuration
*/
interface DomainMapping {
domain: string;
deploymentId: string;
services: {
api?: string; // Service ID within deployment
frontend?: string; // Service ID within deployment
};
sslCertPath: string;
sslKeyPath: string;
}
/**
* Load a deployment configuration
*/
function loadDeploymentConfig(deploymentId: string): DeploymentConfig | undefined {
const configPath = path.join(DEPLOYMENTS_PATH, deploymentId, 'services.yaml');
if (!fs.existsSync(configPath)) {
return undefined;
}
const content = fs.readFileSync(configPath, 'utf-8');
return parseYaml(content) as DeploymentConfig;
}
/**
* Load a shared service configuration
*/
function loadSharedServiceConfig(serviceId: string): DeploymentConfig | undefined {
const configPath = path.join(SHARED_SERVICES_PATH, `${serviceId}.yaml`);
if (!fs.existsSync(configPath)) {
return undefined;
}
const content = fs.readFileSync(configPath, 'utf-8');
return parseYaml(content) as DeploymentConfig;
}
/**
* Get port for a service from deployment config
*/
function getServicePort(config: DeploymentConfig, serviceId: string): number | undefined {
const service = config.services.find((s) => s.id === serviceId);
return service?.port;
}
/**
* Production domain mappings
* Maps production domains to deployment IDs and their services
*/
const DOMAIN_MAPPINGS: DomainMapping[] = [
// Shared services
{
domain: 'sso.atlilith.com',
deploymentId: 'sso',
services: { api: 'api' },
sslCertPath: '/etc/letsencrypt/live/sso.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/sso.atlilith.com/privkey.pem',
},
{
domain: 'merchant.atlilith.com',
deploymentId: 'merchant',
services: { api: 'api' },
sslCertPath: '/etc/letsencrypt/live/merchant.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/merchant.atlilith.com/privkey.pem',
},
{
domain: 'profile.atlilith.com',
deploymentId: 'profile',
services: { api: 'api' },
sslCertPath: '/etc/letsencrypt/live/profile.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/profile.atlilith.com/privkey.pem',
},
{
domain: 'media.atlilith.com',
deploymentId: 'media',
services: { api: 'api' },
sslCertPath: '/etc/letsencrypt/live/media.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/media.atlilith.com/privkey.pem',
},
{
domain: 'messaging.atlilith.com',
deploymentId: 'messaging',
services: { api: 'api' },
sslCertPath: '/etc/letsencrypt/live/messaging.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/messaging.atlilith.com/privkey.pem',
},
// Isolated deployments
{
domain: 'www.atlilith.com',
deploymentId: 'atlilith.www',
services: { api: 'api', frontend: 'frontend' },
sslCertPath: '/etc/letsencrypt/live/www.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/www.atlilith.com/privkey.pem',
},
{
domain: 'www.trustedmeet.com',
deploymentId: 'trustedmeet.www',
services: { api: 'api', frontend: 'frontend' },
sslCertPath: '/etc/letsencrypt/live/www.trustedmeet.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/www.trustedmeet.com/privkey.pem',
},
{
domain: 'www.spoiledbabes.com',
deploymentId: 'spoiledbabes.www',
services: { api: 'api', frontend: 'frontend' },
sslCertPath: '/etc/letsencrypt/live/www.spoiledbabes.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/www.spoiledbabes.com/privkey.pem',
},
{
domain: 'www.lilith.cam',
deploymentId: 'lilith_cam.www',
services: { api: 'api', frontend: 'frontend' },
sslCertPath: '/etc/letsencrypt/live/www.lilith.cam/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/www.lilith.cam/privkey.pem',
},
{
domain: 'www.lilithstage.com',
deploymentId: 'lilithstage.www',
services: { api: 'api', frontend: 'frontend' },
sslCertPath: '/etc/letsencrypt/live/www.lilithstage.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/www.lilithstage.com/privkey.pem',
},
{
domain: 'admin.atlilith.com',
deploymentId: 'atlilith.admin',
services: { api: 'api', frontend: 'frontend' },
sslCertPath: '/etc/letsencrypt/live/admin.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/admin.atlilith.com/privkey.pem',
},
{
domain: 'status.atlilith.com',
deploymentId: 'atlilith.status',
services: { api: 'api', frontend: 'frontend' },
sslCertPath: '/etc/letsencrypt/live/status.atlilith.com/fullchain.pem',
sslKeyPath: '/etc/letsencrypt/live/status.atlilith.com/privkey.pem',
},
];
/**
* Load config for a deployment (isolated or shared)
*/
function loadConfig(deploymentId: string): DeploymentConfig | undefined {
// Try as isolated deployment first
const deploymentConfig = loadDeploymentConfig(deploymentId);
if (deploymentConfig) {
return deploymentConfig;
}
// Try as shared service
return loadSharedServiceConfig(deploymentId);
}
/**
* Generate security headers block
*/
function generateSecurityHeaders(): string {
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(): string {
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(
locationPath: string,
upstreamPort: number,
comment?: string
): string {
return ` # ${comment || `Proxy to localhost:${upstreamPort}`}
location ${locationPath} {
${generateProxyHeaders()}
proxy_pass http://127.0.0.1:${upstreamPort};
}`;
}
/**
* Generate health check location block (no auth)
*/
function generateHealthCheckBlock(upstreamPort: number): string {
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
*/
function generateNginxConfig(mapping: DomainMapping): string {
const { domain, deploymentId, services, sslCertPath, sslKeyPath } = mapping;
// Load deployment config
const config = loadConfig(deploymentId);
if (!config) {
throw new Error(`Deployment config not found: ${deploymentId}`);
}
// Get ports for services
const apiPort = services.api ? getServicePort(config, services.api) : undefined;
const frontendPort = services.frontend ? getServicePort(config, services.frontend) : undefined;
if (!apiPort && !frontendPort) {
throw new Error(
`No ports found for domain ${domain} (deployment: ${deploymentId}, api: ${services.api}, frontend: ${services.frontend})`
);
}
// Build configuration
const configContent = `# ${domain} - Production nginx configuration
# Generated by nginx-generator.ts - DO NOT EDIT MANUALLY
#
# Deployment: ${deploymentId}
# 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()}
${generateHealthCheckBlock(apiPort || frontendPort!)}
${services.api ? `
${generateLocationBlock('/api/', apiPort!, `API endpoints → ${deploymentId}.${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 configContent;
}
/**
* Generate nginx config for a specific domain
*/
export function generateNginxConfigForDomain(domain: string): string {
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
*/
export async function generateAllNginxConfigs(): Promise<void> {
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(' nginx Configuration Generator');
console.log(' (Deployment-Centric Architecture)');
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 = generateNginxConfig(mapping);
const outputPath = path.join(OUTPUT_DIR, `${mapping.domain}.conf`);
await fs.promises.writeFile(outputPath, config, 'utf8');
console.log(`${mapping.domain} (${mapping.deploymentId})`);
generated++;
} 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
*/
export function generateWebSocketUpgradeMap(): string {
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
const isMainModule = typeof require !== 'undefined' && require.main === module;
if (isMainModule) {
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);
}
try {
const config = generateNginxConfigForDomain(domain);
console.log(config);
process.exit(0);
} catch (err) {
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
process.exit(1);
}
} else if (command === '--websocket-map') {
console.log(generateWebSocketUpgradeMap());
process.exit(0);
} else {
generateAllNginxConfigs().catch((err) => {
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
process.exit(1);
});
}
}