510 lines
15 KiB
JavaScript
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);
|
|
});
|
|
}
|
|
}
|