dcrouter/ts/classes.dcrouter.ts

1309 lines
46 KiB
TypeScript

import * as plugins from './plugins.js';
import * as paths from './paths.js';
// Certificate types are available via plugins.tsclass
// Import the email server and its configuration
import { UnifiedEmailServer, type IUnifiedEmailServerOptions } from './mail/routing/classes.unified.email.server.js';
import type { IEmailRoute } from './mail/routing/interfaces.js';
import { logger } from './logger.js';
// Import the email configuration helpers directly from mail/delivery
import { configureEmailStorage, configureEmailServer } from './mail/delivery/index.js';
// Import storage manager
import { StorageManager, type IStorageConfig } from './storage/index.js';
import { OpsServer } from './opsserver/index.js';
export interface IDcRouterOptions {
/**
* Direct SmartProxy configuration - gives full control over HTTP/HTTPS and TCP/SNI traffic
* This is the preferred way to configure HTTP/HTTPS and general TCP/SNI traffic
*/
smartProxyConfig?: plugins.smartproxy.ISmartProxyOptions;
/**
* Email server configuration
* This enables all email handling with pattern-based routing
*/
emailConfig?: IUnifiedEmailServerOptions;
/**
* Custom email port configuration
* Allows configuring specific ports for email handling
* This overrides the default port mapping in the emailConfig
*/
emailPortConfig?: {
/** External to internal port mapping */
portMapping?: Record<number, number>;
/** Custom port configuration for specific ports */
portSettings?: Record<number, any>;
/** Path to store received emails */
receivedEmailsPath?: string;
};
/** TLS/certificate configuration */
tls?: {
/** Contact email for ACME certificates */
contactEmail: string;
/** Domain for main certificate */
domain?: string;
/** Path to certificate file (if not using auto-provisioning) */
certPath?: string;
/** Path to key file (if not using auto-provisioning) */
keyPath?: string;
/** Path to CA certificate file (for custom CAs) */
caPath?: string;
};
/**
* The nameserver domains (e.g., ['ns1.example.com', 'ns2.example.com'])
* These will automatically get A records pointing to publicIp or proxyIps[0]
* These are what go in the NS records for ALL domains in dnsScopes
*/
dnsNsDomains?: string[];
/**
* Domains this DNS server is authoritative for (e.g., ['example.com', 'mail.example.org'])
* NS records will be auto-generated for these domains
* Any DNS record outside these scopes will trigger a warning
* Email domains with `internal-dns` mode must be included here
*/
dnsScopes?: string[];
/**
* IPs of proxies that forward traffic to your server (optional)
* When defined AND useIngressProxy is true, A records with server IP are replaced with proxy IPs
* If not defined or empty, all A records use the real server IP
* Helps hide real server IP for security/privacy
*/
proxyIps?: string[];
/**
* Public IP address for nameserver A records (required if proxyIps not set)
* This is the IP that will be used for the nameserver domains (dnsNsDomains)
* If proxyIps is set, the first proxy IP will be used instead
*/
publicIp?: string;
/**
* DNS records to register
* Must be within the defined dnsScopes (or receive warning)
* Only need A, CNAME, TXT, MX records (NS records auto-generated, SOA handled by smartdns)
* Can use `useIngressProxy: false` to expose real server IP (defaults to true)
*/
dnsRecords?: Array<{
name: string;
type: 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA';
value: string;
ttl?: number;
useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true)
}>;
/** DNS challenge configuration for ACME (optional) */
dnsChallenge?: {
/** Cloudflare API key for DNS challenges */
cloudflareApiKey?: string;
/** Other DNS providers can be added here */
};
/** Storage configuration */
storage?: IStorageConfig;
}
/**
* DcRouter can be run on ingress and egress to and from a datacenter site.
*/
/**
* Context passed to HTTP routing rules
*/
/**
* Context passed to port proxy (SmartProxy) routing rules
*/
export interface PortProxyRuleContext {
proxy: plugins.smartproxy.SmartProxy;
routes: plugins.smartproxy.IRouteConfig[];
}
export class DcRouter {
public options: IDcRouterOptions;
// Core services
public smartProxy?: plugins.smartproxy.SmartProxy;
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
public emailServer?: UnifiedEmailServer;
public storageManager: StorageManager;
public opsServer: OpsServer;
// Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
constructor(optionsArg: IDcRouterOptions) {
// Set defaults in options
this.options = {
...optionsArg
};
// Initialize storage manager
this.storageManager = new StorageManager(this.options.storage);
}
public async start() {
console.log('╔═══════════════════════════════════════════════════════════════════╗');
console.log('║ Starting DcRouter Services ║');
console.log('╚═══════════════════════════════════════════════════════════════════╝');
this.opsServer = new OpsServer(this);
await this.opsServer.start();
try {
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
await this.setupSmartProxy();
// Set up unified email handling if configured
if (this.options.emailConfig) {
await this.setupUnifiedEmailHandling();
// Apply custom email storage configuration if available
if (this.emailServer && this.options.emailPortConfig?.receivedEmailsPath) {
logger.log('info', 'Applying custom email storage configuration');
configureEmailStorage(this.emailServer, this.options);
}
}
// Set up DNS server if configured with nameservers and scopes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 &&
this.options.dnsScopes && this.options.dnsScopes.length > 0) {
await this.setupDnsWithSocketHandler();
}
this.logStartupSummary();
} catch (error) {
console.error('❌ Error starting DcRouter:', error);
// Try to clean up any services that may have started
await this.stop();
throw error;
}
}
/**
* Log comprehensive startup summary
*/
private logStartupSummary(): void {
console.log('\n╔═══════════════════════════════════════════════════════════════════╗');
console.log('║ DcRouter Started Successfully ║');
console.log('╚═══════════════════════════════════════════════════════════════════╝\n');
// SmartProxy summary
if (this.smartProxy) {
console.log('🌐 SmartProxy Service:');
const routeCount = this.options.smartProxyConfig?.routes?.length || 0;
console.log(` ├─ Routes configured: ${routeCount}`);
console.log(` ├─ ACME enabled: ${this.options.smartProxyConfig?.acme?.enabled || false}`);
if (this.options.smartProxyConfig?.acme?.enabled) {
console.log(` ├─ ACME email: ${this.options.smartProxyConfig.acme.email || 'not set'}`);
console.log(` └─ ACME mode: ${this.options.smartProxyConfig.acme.useProduction ? 'production' : 'staging'}`);
} else {
console.log(' └─ ACME: disabled');
}
}
// Email service summary
if (this.emailServer && this.options.emailConfig) {
console.log('\n📧 Email Service:');
const ports = this.options.emailConfig.ports || [];
console.log(` ├─ Ports: ${ports.join(', ')}`);
console.log(` ├─ Hostname: ${this.options.emailConfig.hostname || 'localhost'}`);
console.log(` ├─ Domains configured: ${this.options.emailConfig.domains?.length || 0}`);
if (this.options.emailConfig.domains && this.options.emailConfig.domains.length > 0) {
this.options.emailConfig.domains.forEach((domain, index) => {
const isLast = index === this.options.emailConfig!.domains!.length - 1;
console.log(` ${isLast ? '└─' : '├─'} ${domain.domain} (${domain.dnsMode || 'default'})`);
});
}
console.log(` └─ DKIM: Initialized for all domains`);
}
// DNS service summary
if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) {
console.log('\n🌍 DNS Service:');
console.log(` ├─ Nameservers: ${this.options.dnsNsDomains.join(', ')}`);
console.log(` ├─ Primary NS: ${this.options.dnsNsDomains[0]}`);
console.log(` ├─ Authoritative for: ${this.options.dnsScopes.length} domains`);
console.log(` ├─ UDP Port: 53`);
console.log(` ├─ DNS-over-HTTPS: Enabled via socket handler`);
console.log(` └─ DNSSEC: ${this.options.dnsNsDomains[0] ? 'Enabled' : 'Disabled'}`);
// Show authoritative domains
if (this.options.dnsScopes.length > 0) {
console.log('\n Authoritative Domains:');
this.options.dnsScopes.forEach((domain, index) => {
const isLast = index === this.options.dnsScopes!.length - 1;
console.log(` ${isLast ? '└─' : '├─'} ${domain}`);
});
}
}
// Storage summary
if (this.storageManager && this.options.storage) {
console.log('\n💾 Storage:');
console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`);
}
console.log('\n✅ All services are running\n');
}
/**
* Set up SmartProxy with direct configuration and automatic email routes
*/
private async setupSmartProxy(): Promise<void> {
console.log('[DcRouter] Setting up SmartProxy...');
let routes: plugins.smartproxy.IRouteConfig[] = [];
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
// If user provides full SmartProxy config, use it directly
if (this.options.smartProxyConfig) {
routes = this.options.smartProxyConfig.routes || [];
acmeConfig = this.options.smartProxyConfig.acme;
console.log(`[DcRouter] Found ${routes.length} routes in config`);
console.log(`[DcRouter] ACME config present: ${!!acmeConfig}`);
}
// If email config exists, automatically add email routes
if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
console.log(`Email Routes are:`)
console.log(emailRoutes)
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
}
// If DNS is configured, add DNS routes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes();
console.log(`DNS Routes for nameservers ${this.options.dnsNsDomains.join(', ')}:`, dnsRoutes);
routes = [...routes, ...dnsRoutes];
}
// Merge TLS/ACME configuration if provided at root level
if (this.options.tls && !acmeConfig) {
acmeConfig = {
accountEmail: this.options.tls.contactEmail,
enabled: true,
useProduction: true,
autoRenew: true,
renewThresholdDays: 30
};
}
// Configure DNS challenge if available
let challengeHandlers: any[] = [];
if (this.options.dnsChallenge?.cloudflareApiKey) {
console.log('Configuring Cloudflare DNS challenge for ACME');
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
challengeHandlers.push(dns01Handler);
}
// If we have routes or need a basic SmartProxy instance, create it
if (routes.length > 0 || this.options.smartProxyConfig) {
console.log('Setting up SmartProxy with combined configuration');
// Create SmartProxy configuration
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
...this.options.smartProxyConfig,
routes,
acme: acmeConfig
};
// If we have DNS challenge handlers, enhance the config
if (challengeHandlers.length > 0) {
// We'll need to pass this to SmartProxy somehow
// For now, we'll set it as a property
(smartProxyConfig as any).acmeChallengeHandlers = challengeHandlers;
(smartProxyConfig as any).acmeChallengePriority = ['dns-01', 'http-01'];
}
// Create SmartProxy instance
console.log('[DcRouter] Creating SmartProxy instance with config:', JSON.stringify({
routeCount: smartProxyConfig.routes?.length,
acmeEnabled: smartProxyConfig.acme?.enabled,
acmeEmail: smartProxyConfig.acme?.email,
certProvisionFunction: !!smartProxyConfig.certProvisionFunction
}, null, 2));
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
// Set up event listeners
this.smartProxy.on('error', (err) => {
console.error('[DcRouter] SmartProxy error:', err);
console.error('[DcRouter] Error stack:', err.stack);
});
if (acmeConfig) {
this.smartProxy.on('certificate-issued', (event) => {
console.log(`[DcRouter] Certificate issued for ${event.domain}, expires ${event.expiryDate}`);
});
this.smartProxy.on('certificate-renewed', (event) => {
console.log(`[DcRouter] Certificate renewed for ${event.domain}, expires ${event.expiryDate}`);
});
this.smartProxy.on('certificate-failed', (event) => {
console.error(`[DcRouter] Certificate failed for ${event.domain}:`, event.error);
});
}
// Start SmartProxy
console.log('[DcRouter] Starting SmartProxy...');
await this.smartProxy.start();
console.log('[DcRouter] SmartProxy started successfully');
console.log(`SmartProxy started with ${routes.length} routes`);
}
}
/**
* Generate SmartProxy routes for email configuration
*/
private generateEmailRoutes(emailConfig: IUnifiedEmailServerOptions): plugins.smartproxy.IRouteConfig[] {
const emailRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Create routes for each email port
for (const port of emailConfig.ports) {
// Create a descriptive name for the route based on the port
let routeName = 'email-route';
let tlsMode = 'passthrough';
// Handle different email ports differently
switch (port) {
case 25: // SMTP
routeName = 'smtp-route';
tlsMode = 'passthrough'; // STARTTLS handled by email server
break;
case 587: // Submission
routeName = 'submission-route';
tlsMode = 'passthrough'; // STARTTLS handled by email server
break;
case 465: // SMTPS
routeName = 'smtps-route';
tlsMode = 'terminate'; // Terminate TLS and re-encrypt to email server
break;
default:
routeName = `email-port-${port}-route`;
tlsMode = 'passthrough';
// Check if we have specific settings for this port
if (this.options.emailPortConfig?.portSettings &&
this.options.emailPortConfig.portSettings[port]) {
const portSettings = this.options.emailPortConfig.portSettings[port];
// If this port requires TLS termination, set the mode accordingly
if (portSettings.terminateTls) {
tlsMode = 'terminate';
}
// Override the route name if specified
if (portSettings.routeName) {
routeName = portSettings.routeName;
}
}
break;
}
// Create action based on mode
let action: any;
if (emailConfig.useSocketHandler) {
// Socket-handler mode
action = {
type: 'socket-handler' as any,
socketHandler: this.createMailSocketHandler(port)
};
} else {
// Traditional forwarding mode
const defaultPortMapping = {
25: 10025, // SMTP
587: 10587, // Submission
465: 10465 // SMTPS
};
const portMapping = this.options.emailPortConfig?.portMapping || defaultPortMapping;
const internalPort = portMapping[port] || port + 10000;
action = {
type: 'forward',
target: {
host: 'localhost', // Forward to internal email server
port: internalPort
},
tls: {
mode: tlsMode as any
}
};
}
// For TLS terminate mode, add certificate info
if (tlsMode === 'terminate' && action.tls) {
action.tls.certificate = 'auto';
}
// Create the route configuration
const routeConfig: plugins.smartproxy.IRouteConfig = {
name: routeName,
match: {
ports: [port]
},
action: action
};
// Add the route to our list
emailRoutes.push(routeConfig);
}
// Add email domain-based routes if configured
if (emailConfig.routes) {
for (const route of emailConfig.routes) {
emailRoutes.push({
name: route.name,
match: {
ports: emailConfig.ports,
domains: route.match.recipients ? [route.match.recipients.toString().split('@')[1]] : []
},
action: {
type: 'forward',
target: route.action.type === 'forward' && route.action.forward ? {
host: route.action.forward.host,
port: route.action.forward.port || 25
} : undefined,
tls: {
mode: 'passthrough'
}
}
});
}
}
return emailRoutes;
}
/**
* Generate SmartProxy routes for DNS configuration
*/
private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] {
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
return [];
}
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Create routes for DNS-over-HTTPS paths
const dohPaths = ['/dns-query', '/resolve'];
// Use the first nameserver domain for DoH routes
const primaryNameserver = this.options.dnsNsDomains[0];
for (const path of dohPaths) {
const dohRoute: plugins.smartproxy.IRouteConfig = {
name: `dns-over-https-${path.replace('/', '')}`,
match: {
ports: [443], // HTTPS port for DoH
domains: [primaryNameserver],
path: path
},
action: {
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler()
} as any
};
dnsRoutes.push(dohRoute);
}
return dnsRoutes;
}
/**
* Check if a domain matches a pattern (including wildcard support)
* @param domain The domain to check
* @param pattern The pattern to match against (e.g., "*.example.com")
* @returns Whether the domain matches the pattern
*/
private isDomainMatch(domain: string, pattern: string): boolean {
// Normalize inputs
domain = domain.toLowerCase();
pattern = pattern.toLowerCase();
// Check for exact match
if (domain === pattern) {
return true;
}
// Check for wildcard match (*.example.com)
if (pattern.startsWith('*.')) {
const patternSuffix = pattern.slice(2); // Remove the "*." prefix
// Check if domain ends with the pattern suffix and has at least one character before it
return domain.endsWith(patternSuffix) && domain.length > patternSuffix.length;
}
// No match
return false;
}
public async stop() {
console.log('Stopping DcRouter services...');
await this.opsServer.stop();
try {
// Stop all services in parallel for faster shutdown
await Promise.all([
// Stop unified email server if running
this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(),
// Stop HTTP SmartProxy if running
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
// Stop DNS server if running
this.dnsServer ?
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
Promise.resolve()
]);
console.log('All DcRouter services stopped');
} catch (error) {
console.error('Error during DcRouter shutdown:', error);
throw error;
}
}
/**
* Update SmartProxy configuration
* @param config New SmartProxy configuration
*/
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
// Stop existing SmartProxy if running
if (this.smartProxy) {
await this.smartProxy.stop();
this.smartProxy = undefined;
}
// Update configuration
this.options.smartProxyConfig = config;
// Start new SmartProxy with updated configuration (will include email routes if configured)
await this.setupSmartProxy();
console.log('SmartProxy configuration updated');
}
/**
* Set up unified email handling with pattern-based routing
* This implements the consolidated emailConfig approach
*/
private async setupUnifiedEmailHandling(): Promise<void> {
if (!this.options.emailConfig) {
throw new Error('Email configuration is required for unified email handling');
}
// Apply port mapping if behind SmartProxy
const portMapping = this.options.emailPortConfig?.portMapping || {
25: 10025, // SMTP
587: 10587, // Submission
465: 10465 // SMTPS
};
// Create config with mapped ports
const emailConfig: IUnifiedEmailServerOptions = {
...this.options.emailConfig,
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding
};
// Create unified email server
this.emailServer = new UnifiedEmailServer(this, emailConfig);
// Set up error handling
this.emailServer.on('error', (err: Error) => {
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
});
// Start the server
await this.emailServer.start();
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
}
/**
* Update the unified email configuration
* @param config New email configuration
*/
public async updateEmailConfig(config: IUnifiedEmailServerOptions): Promise<void> {
// Stop existing email components
await this.stopUnifiedEmailComponents();
// Update configuration
this.options.emailConfig = config;
// Start email handling with new configuration
await this.setupUnifiedEmailHandling();
console.log('Unified email configuration updated');
}
/**
* Stop all unified email components
*/
private async stopUnifiedEmailComponents(): Promise<void> {
try {
// Stop the unified email server which contains all components
if (this.emailServer) {
await this.emailServer.stop();
logger.log('info', 'Unified email server stopped');
this.emailServer = undefined;
}
logger.log('info', 'All unified email components stopped');
} catch (error) {
logger.log('error', `Error stopping unified email components: ${error.message}`);
throw error;
}
}
/**
* Update domain rules for email routing
* @param rules New domain rules to apply
*/
public async updateEmailRoutes(routes: IEmailRoute[]): Promise<void> {
// Validate that email config exists
if (!this.options.emailConfig) {
throw new Error('Email configuration is required before updating routes');
}
// Update the configuration
this.options.emailConfig.routes = routes;
// Update the unified email server if it exists
if (this.emailServer) {
this.emailServer.updateRoutes(routes);
}
console.log(`Email routes updated with ${routes.length} routes`);
}
/**
* Get statistics from all components
*/
public getStats(): any {
const stats: any = {
emailServer: this.emailServer?.getStats()
};
return stats;
}
/**
* Configure MTA for email handling with custom port and storage settings
* @param config Configuration for the MTA service
*/
public async configureEmailMta(config: {
internalPort: number;
host?: string;
secure?: boolean;
storagePath?: string;
portMapping?: Record<number, number>;
}): Promise<boolean> {
logger.log('info', 'Configuring MTA service with custom settings');
// Update email port configuration
if (!this.options.emailPortConfig) {
this.options.emailPortConfig = {};
}
// Configure storage paths for received emails
if (config.storagePath) {
// Set the storage path for received emails
this.options.emailPortConfig.receivedEmailsPath = config.storagePath;
}
// Apply port mapping if provided
if (config.portMapping) {
this.options.emailPortConfig.portMapping = {
...this.options.emailPortConfig.portMapping,
...config.portMapping
};
logger.log('info', `Updated MTA port mappings: ${JSON.stringify(this.options.emailPortConfig.portMapping)}`);
}
// Use the dedicated helper to configure the email server
// Pass through the options specified by the implementation
if (this.emailServer) {
configureEmailServer(this.emailServer, {
ports: [config.internalPort], // Use whatever port the implementation specifies
hostname: config.host,
tls: config.secure ? {
// Basic TLS settings if secure mode is enabled
certPath: this.options.tls?.certPath,
keyPath: this.options.tls?.keyPath,
caPath: this.options.tls?.caPath
} : undefined,
storagePath: config.storagePath
});
}
// If email handling is already set up, restart it to apply changes
if (this.emailServer) {
logger.log('info', 'Restarting unified email handling to apply MTA configuration changes');
await this.stopUnifiedEmailComponents();
await this.setupUnifiedEmailHandling();
}
return true;
}
/**
* Register DNS records with the DNS server
* @param records Array of DNS records to register
*/
private registerDnsRecords(records: Array<{name: string; type: string; value: string; ttl?: number}>): void {
if (!this.dnsServer) return;
// Register a separate handler for each record
// This ensures multiple records of the same type (like NS records) are all served
for (const record of records) {
// Register handler for this specific record
this.dnsServer.registerHandler(record.name, [record.type], (question) => {
// Check if this handler matches the question
if (question.name === record.name && question.type === record.type) {
return {
name: record.name,
type: record.type,
class: 'IN',
ttl: record.ttl || 300,
data: this.parseDnsRecordData(record.type, record.value)
};
}
return null;
});
}
logger.log('info', `Registered ${records.length} DNS handlers (one per record)`);
}
/**
* Parse DNS record data based on record type
* @param type DNS record type
* @param value DNS record value
* @returns Parsed data for the DNS response
*/
private parseDnsRecordData(type: string, value: string): any {
switch (type) {
case 'A':
return value; // IP address as string
case 'MX':
const [priority, exchange] = value.split(' ');
return { priority: parseInt(priority), exchange };
case 'TXT':
return value;
case 'NS':
return value;
case 'SOA':
// SOA format: primary-ns admin-email serial refresh retry expire minimum
const parts = value.split(' ');
return {
mname: parts[0],
rname: parts[1],
serial: parseInt(parts[2]),
refresh: parseInt(parts[3]),
retry: parseInt(parts[4]),
expire: parseInt(parts[5]),
minimum: parseInt(parts[6])
};
default:
return value;
}
}
/**
* Set up DNS server with socket handler for DoH
*/
private async setupDnsWithSocketHandler(): Promise<void> {
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
throw new Error('dnsNsDomains is required for DNS server setup');
}
if (!this.options.dnsScopes || this.options.dnsScopes.length === 0) {
throw new Error('dnsScopes is required for DNS server setup');
}
const primaryNameserver = this.options.dnsNsDomains[0];
logger.log('info', `Setting up DNS server with primary nameserver: ${primaryNameserver}`);
// Get VM IP address for UDP binding
const networkInterfaces = plugins.os.networkInterfaces();
let vmIpAddress = '0.0.0.0'; // Default to all interfaces
// Try to find the VM's internal IP address
for (const [_name, interfaces] of Object.entries(networkInterfaces)) {
if (interfaces) {
for (const iface of interfaces) {
if (!iface.internal && iface.family === 'IPv4') {
vmIpAddress = iface.address;
break;
}
}
}
}
// Create DNS server instance with manual HTTPS mode
this.dnsServer = new plugins.smartdns.dnsServerMod.DnsServer({
udpPort: 53,
udpBindInterface: vmIpAddress,
httpsPort: 443, // Required but won't bind due to manual mode
manualHttpsMode: true, // Enable manual HTTPS socket handling
dnssecZone: primaryNameserver,
primaryNameserver: primaryNameserver, // Automatically generates correct SOA records
// For now, use self-signed cert until we integrate with Let's Encrypt
httpsKey: '',
httpsCert: ''
});
// Start the DNS server (UDP only)
await this.dnsServer.start();
logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`);
// Validate DNS configuration
await this.validateDnsConfiguration();
// Generate and register authoritative records
const authoritativeRecords = await this.generateAuthoritativeRecords();
// Generate email DNS records
const emailDnsRecords = await this.generateEmailDnsRecords();
// Initialize DKIM for all email domains
await this.initializeDkimForEmailDomains();
// Load DKIM records from JSON files (they should now exist)
const dkimRecords = await this.loadDkimRecords();
// Combine all records: authoritative, email, DKIM, and user-defined
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
if (this.options.dnsRecords && this.options.dnsRecords.length > 0) {
allRecords.push(...this.options.dnsRecords);
}
// Apply proxy IP replacement if configured
await this.applyProxyIpReplacement(allRecords);
// Register all DNS records
if (allRecords.length > 0) {
this.registerDnsRecords(allRecords);
logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
}
}
/**
* Create DNS socket handler for DoH
*/
private createDnsSocketHandler(): (socket: plugins.net.Socket) => Promise<void> {
return async (socket: plugins.net.Socket) => {
if (!this.dnsServer) {
logger.log('error', 'DNS socket handler called but DNS server not initialized');
socket.end();
return;
}
logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
try {
// Use the built-in socket handler from smartdns
// This handles HTTP/2, DoH protocol, etc.
await (this.dnsServer as any).handleHttpsSocket(socket);
} catch (error) {
logger.log('error', `DNS socket handler error: ${error.message}`);
socket.destroy();
}
};
}
/**
* Validate DNS configuration
*/
private async validateDnsConfiguration(): Promise<void> {
if (!this.options.dnsNsDomains || !this.options.dnsScopes) {
return;
}
logger.log('info', 'Validating DNS configuration...');
// Check if email domains with internal-dns are in dnsScopes
if (this.options.emailConfig?.domains) {
for (const domainConfig of this.options.emailConfig.domains) {
if (domainConfig.dnsMode === 'internal-dns' &&
!this.options.dnsScopes.includes(domainConfig.domain)) {
logger.log('warn', `Email domain '${domainConfig.domain}' with internal-dns mode is not in dnsScopes. It should be added to dnsScopes.`);
}
}
}
// Validate user-provided DNS records are within scopes
if (this.options.dnsRecords) {
for (const record of this.options.dnsRecords) {
const recordDomain = this.extractDomain(record.name);
const isInScope = this.options.dnsScopes.some(scope =>
recordDomain === scope || recordDomain.endsWith(`.${scope}`)
);
if (!isInScope) {
logger.log('warn', `DNS record for '${record.name}' is outside defined scopes [${this.options.dnsScopes.join(', ')}]`);
}
}
}
}
/**
* Generate email DNS records for domains with internal-dns mode
*/
private async generateEmailDnsRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
if (!this.options.emailConfig?.domains) {
return records;
}
// Filter domains with internal-dns mode
const internalDnsDomains = this.options.emailConfig.domains.filter(
domain => domain.dnsMode === 'internal-dns'
);
for (const domainConfig of internalDnsDomains) {
const domain = domainConfig.domain;
const ttl = domainConfig.dns?.internal?.ttl || 3600;
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10;
// MX record - points to the domain itself for email handling
records.push({
name: domain,
type: 'MX',
value: `${mxPriority} ${domain}`,
ttl
});
// SPF record - using sensible defaults
const spfRecord = 'v=spf1 a mx ~all';
records.push({
name: domain,
type: 'TXT',
value: spfRecord,
ttl
});
// DMARC record - using sensible defaults
const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
const dmarcEmail = `dmarc@${domain}`;
records.push({
name: `_dmarc.${domain}`,
type: 'TXT',
value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
ttl
});
// Note: DKIM records will be generated later when DKIM keys are available
// They require the DKIMCreator which is part of the email server
}
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
return records;
}
/**
* Load DKIM records from JSON files
* Reads all *.dkimrecord.json files from the DNS records directory
*/
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
try {
// Ensure paths are imported
const dnsDir = paths.dnsRecordsDir;
// Check if directory exists
if (!plugins.fs.existsSync(dnsDir)) {
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
return records;
}
// Read all files in the directory
const files = plugins.fs.readdirSync(dnsDir);
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
// Load each DKIM record
for (const file of dkimFiles) {
try {
const filePath = plugins.path.join(dnsDir, file);
const fileContent = plugins.fs.readFileSync(filePath, 'utf8');
const dkimRecord = JSON.parse(fileContent);
// Validate record structure
if (dkimRecord.name && dkimRecord.type === 'TXT' && dkimRecord.value) {
records.push({
name: dkimRecord.name,
type: 'TXT',
value: dkimRecord.value,
ttl: 3600 // Standard DKIM TTL
});
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
} else {
logger.log('warn', `Invalid DKIM record structure in ${file}`);
}
} catch (error) {
logger.log('error', `Failed to load DKIM record from ${file}: ${error.message}`);
}
}
} catch (error) {
logger.log('error', `Failed to load DKIM records: ${error.message}`);
}
return records;
}
/**
* Initialize DKIM keys for all configured email domains
* This ensures DKIM records are available immediately at startup
*/
private async initializeDkimForEmailDomains(): Promise<void> {
if (!this.options.emailConfig?.domains || !this.emailServer) {
return;
}
logger.log('info', 'Initializing DKIM keys for email domains...');
// Get DKIMCreator instance from email server
const dkimCreator = (this.emailServer as any).dkimCreator;
if (!dkimCreator) {
logger.log('warn', 'DKIMCreator not available, skipping DKIM initialization');
return;
}
// Ensure necessary directories exist
paths.ensureDirectories();
// Generate DKIM keys for each email domain
for (const domainConfig of this.options.emailConfig.domains) {
try {
// Generate DKIM keys for all domains, regardless of DNS mode
// This ensures keys are ready even if DNS mode changes later
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
} catch (error) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${error.message}`);
}
}
logger.log('info', 'DKIM initialization complete');
}
/**
* Generate authoritative DNS records (NS only) for all domains in dnsScopes
* SOA records are now automatically generated by smartdns with primaryNameserver setting
*/
private async generateAuthoritativeRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
if (!this.options.dnsNsDomains || !this.options.dnsScopes) {
return records;
}
// Determine the public IP for nameserver A records
let publicIp: string | null = null;
// Use proxy IPs if configured (these should be public IPs)
if (this.options.proxyIps && this.options.proxyIps.length > 0) {
publicIp = this.options.proxyIps[0]; // Use first proxy IP
logger.log('info', `Using proxy IP for nameserver A records: ${publicIp}`);
} else if (this.options.publicIp) {
// Use explicitly configured public IP
publicIp = this.options.publicIp;
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
} else {
// Auto-discover public IP using smartnetwork
try {
logger.log('info', 'Auto-discovering public IP address...');
const smartNetwork = new plugins.smartnetwork.SmartNetwork();
const publicIps = await smartNetwork.getPublicIps();
if (publicIps.v4) {
publicIp = publicIps.v4;
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
} else {
logger.log('warn', 'Could not auto-discover public IPv4 address');
}
} catch (error) {
logger.log('error', `Failed to auto-discover public IP: ${error.message}`);
}
if (!publicIp) {
logger.log('warn', 'No public IP available. Nameserver A records require either proxyIps, publicIp, or successful auto-discovery.');
}
}
// Generate A records for nameservers if we have a public IP
if (publicIp) {
for (const nsDomain of this.options.dnsNsDomains) {
records.push({
name: nsDomain,
type: 'A',
value: publicIp,
ttl: 3600
});
}
logger.log('info', `Generated A records for ${this.options.dnsNsDomains.length} nameservers`);
}
// Generate NS records for each domain in scopes
for (const domain of this.options.dnsScopes) {
// Add NS records for all nameservers
for (const nsDomain of this.options.dnsNsDomains) {
records.push({
name: domain,
type: 'NS',
value: nsDomain,
ttl: 3600
});
}
// SOA records are now automatically generated by smartdns DnsServer
// with the primaryNameserver configuration option
}
logger.log('info', `Generated ${records.length} total records (A + NS) for ${this.options.dnsScopes.length} domains`);
return records;
}
/**
* Extract the base domain from a DNS record name
*/
private extractDomain(recordName: string): string {
// Handle wildcards
if (recordName.startsWith('*.')) {
recordName = recordName.substring(2);
}
return recordName;
}
/**
* Apply proxy IP replacement logic to DNS records
*/
private async applyProxyIpReplacement(records: Array<{name: string; type: string; value: string; ttl?: number; useIngressProxy?: boolean}>): Promise<void> {
if (!this.options.proxyIps || this.options.proxyIps.length === 0) {
return; // No proxy IPs configured, skip replacement
}
// Get server's public IP
const serverIp = await this.detectServerPublicIp();
if (!serverIp) {
logger.log('warn', 'Could not detect server public IP, skipping proxy IP replacement');
return;
}
logger.log('info', `Applying proxy IP replacement. Server IP: ${serverIp}, Proxy IPs: ${this.options.proxyIps.join(', ')}`);
let proxyIndex = 0;
for (const record of records) {
if (record.type === 'A' &&
record.value === serverIp &&
record.useIngressProxy !== false) {
// Round-robin through proxy IPs
const proxyIp = this.options.proxyIps[proxyIndex % this.options.proxyIps.length];
logger.log('info', `Replacing A record for ${record.name}: ${record.value}${proxyIp}`);
record.value = proxyIp;
proxyIndex++;
}
}
}
/**
* Detect the server's public IP address
*/
private async detectServerPublicIp(): Promise<string | null> {
try {
const smartNetwork = new plugins.smartnetwork.SmartNetwork();
const publicIps = await smartNetwork.getPublicIps();
if (publicIps.v4) {
return publicIps.v4;
}
return null;
} catch (error) {
logger.log('warn', `Failed to detect public IP: ${error.message}`);
return null;
}
}
/**
* Create mail socket handler for email traffic
*/
private createMailSocketHandler(port: number): (socket: plugins.net.Socket) => Promise<void> {
return async (socket: plugins.net.Socket) => {
if (!this.emailServer) {
logger.log('error', 'Mail socket handler called but email server not initialized');
socket.end();
return;
}
logger.log('debug', `Mail socket handler: handling connection for port ${port}`);
try {
// Port 465 requires immediate TLS
if (port === 465) {
// Wrap the socket in TLS
const tlsOptions = {
isServer: true,
key: this.options.tls?.keyPath ? plugins.fs.readFileSync(this.options.tls.keyPath, 'utf8') : undefined,
cert: this.options.tls?.certPath ? plugins.fs.readFileSync(this.options.tls.certPath, 'utf8') : undefined
};
const tlsSocket = new plugins.tls.TLSSocket(socket, tlsOptions);
tlsSocket.on('secure', () => {
// Pass the secure socket to the email server
this.emailServer!.handleSocket(tlsSocket, port);
});
tlsSocket.on('error', (err) => {
logger.log('error', `TLS handshake error on port ${port}: ${err.message}`);
socket.destroy();
});
} else {
// For ports 25 and 587, pass raw socket (STARTTLS handled by email server)
await this.emailServer.handleSocket(socket, port);
}
} catch (error) {
logger.log('error', `Mail socket handler error on port ${port}: ${error.message}`);
socket.destroy();
}
};
}
}
// Re-export email server types for convenience
export type { IUnifiedEmailServerOptions };
export default DcRouter;