1746 lines
63 KiB
TypeScript
1746 lines
63 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 from smartmta
|
|
import {
|
|
UnifiedEmailServer,
|
|
type IUnifiedEmailServerOptions,
|
|
type IEmailRoute,
|
|
type IEmailDomainConfig,
|
|
} from '@push.rocks/smartmta';
|
|
import { logger } from './logger.js';
|
|
// Import storage manager
|
|
import { StorageManager, type IStorageConfig } from './storage/index.js';
|
|
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
|
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
|
|
// Import cache system
|
|
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
|
|
|
|
import { OpsServer } from './opsserver/index.js';
|
|
import { MetricsManager } from './monitoring/index.js';
|
|
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
|
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
|
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
|
|
|
export interface IDcRouterOptions {
|
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
|
baseDir?: string;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* Cache database configuration using smartdata and LocalTsmDb
|
|
* Provides persistent caching for emails, IP reputation, bounces, etc.
|
|
*/
|
|
cacheConfig?: {
|
|
/** Enable cache database (default: true) */
|
|
enabled?: boolean;
|
|
/** Storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
|
storagePath?: string;
|
|
/** Database name (default: dcrouter) */
|
|
dbName?: string;
|
|
/** Default TTL in days for cached items (default: 30) */
|
|
defaultTTLDays?: number;
|
|
/** Cleanup interval in hours (default: 1) */
|
|
cleanupIntervalHours?: number;
|
|
/** TTL configuration per data type (in days) */
|
|
ttlConfig?: {
|
|
/** Email cache TTL (default: 30 days) */
|
|
emails?: number;
|
|
/** IP reputation cache TTL (default: 1 day) */
|
|
ipReputation?: number;
|
|
/** Bounce records TTL (default: 30 days) */
|
|
bounces?: number;
|
|
/** DKIM keys TTL (default: 90 days) */
|
|
dkimKeys?: number;
|
|
/** Suppression list TTL (default: 30 days, can be permanent) */
|
|
suppression?: number;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* RADIUS server configuration for network authentication
|
|
* Enables MAC Authentication Bypass (MAB) and VLAN assignment
|
|
*/
|
|
radiusConfig?: IRadiusServerConfig;
|
|
|
|
/**
|
|
* Remote Ingress configuration for edge tunnel nodes
|
|
* Enables edge nodes to accept incoming connections and tunnel them to this DcRouter
|
|
*/
|
|
remoteIngressConfig?: {
|
|
/** Enable remote ingress hub (default: false) */
|
|
enabled?: boolean;
|
|
/** Port for tunnel connections from edge nodes (default: 8443) */
|
|
tunnelPort?: number;
|
|
/** External hostname of this hub, embedded in connection tokens */
|
|
hubDomain?: string;
|
|
/** TLS configuration for the tunnel server */
|
|
tls?: {
|
|
certPath?: string;
|
|
keyPath?: string;
|
|
};
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
public resolvedPaths: ReturnType<typeof paths.resolvePaths>;
|
|
|
|
// Core services
|
|
public smartProxy?: plugins.smartproxy.SmartProxy;
|
|
public smartAcme?: plugins.smartacme.SmartAcme;
|
|
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
|
public emailServer?: UnifiedEmailServer;
|
|
public radiusServer?: RadiusServer;
|
|
public storageManager: StorageManager;
|
|
public opsServer: OpsServer;
|
|
public metricsManager?: MetricsManager;
|
|
|
|
// Cache system (smartdata + LocalTsmDb)
|
|
public cacheDb?: CacheDb;
|
|
public cacheCleaner?: CacheCleaner;
|
|
|
|
// Remote Ingress
|
|
public remoteIngressManager?: RemoteIngressManager;
|
|
public tunnelManager?: TunnelManager;
|
|
|
|
// Programmatic config API
|
|
public routeConfigManager?: RouteConfigManager;
|
|
public apiTokenManager?: ApiTokenManager;
|
|
|
|
// DNS query logging rate limiter state
|
|
private dnsLogWindow: number[] = [];
|
|
private dnsBatchCount: number = 0;
|
|
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Certificate status tracking from SmartProxy events (keyed by domain)
|
|
public certificateStatusMap = new Map<string, {
|
|
status: 'valid' | 'failed';
|
|
routeNames: string[];
|
|
expiryDate?: string;
|
|
issuedAt?: string;
|
|
source?: string;
|
|
error?: string;
|
|
}>();
|
|
|
|
// Certificate provisioning scheduler with per-domain backoff
|
|
public certProvisionScheduler?: CertProvisionScheduler;
|
|
|
|
// TypedRouter for API endpoints
|
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
|
|
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
|
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
|
|
|
// Environment access
|
|
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
|
|
|
constructor(optionsArg: IDcRouterOptions) {
|
|
// Set defaults in options
|
|
this.options = {
|
|
...optionsArg
|
|
};
|
|
|
|
// Resolve all data paths from baseDir
|
|
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
|
|
|
// Default storage to filesystem if not configured
|
|
if (!this.options.storage) {
|
|
this.options.storage = {
|
|
fsPath: this.resolvedPaths.defaultStoragePath,
|
|
};
|
|
}
|
|
|
|
// Initialize storage manager
|
|
this.storageManager = new StorageManager(this.options.storage);
|
|
}
|
|
|
|
public async start() {
|
|
logger.log('info', 'Starting DcRouter Services');
|
|
|
|
|
|
this.opsServer = new OpsServer(this);
|
|
await this.opsServer.start();
|
|
|
|
try {
|
|
// Initialize cache database if enabled (default: enabled)
|
|
if (this.options.cacheConfig?.enabled !== false) {
|
|
await this.setupCacheDb();
|
|
}
|
|
|
|
// Initialize MetricsManager
|
|
this.metricsManager = new MetricsManager(this);
|
|
await this.metricsManager.start();
|
|
|
|
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
|
|
await this.setupSmartProxy();
|
|
|
|
// Initialize programmatic config API managers
|
|
this.routeConfigManager = new RouteConfigManager(
|
|
this.storageManager,
|
|
() => this.getConstructorRoutes(),
|
|
() => this.smartProxy,
|
|
);
|
|
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
|
await this.apiTokenManager.initialize();
|
|
await this.routeConfigManager.initialize();
|
|
|
|
// Set up unified email handling if configured
|
|
if (this.options.emailConfig) {
|
|
await this.setupUnifiedEmailHandling();
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Set up RADIUS server if configured
|
|
if (this.options.radiusConfig) {
|
|
await this.setupRadiusServer();
|
|
}
|
|
|
|
// Set up Remote Ingress hub if configured
|
|
if (this.options.remoteIngressConfig?.enabled) {
|
|
await this.setupRemoteIngress();
|
|
}
|
|
|
|
this.logStartupSummary();
|
|
} catch (error) {
|
|
logger.log('error', 'Error starting DcRouter', { error: String(error) });
|
|
// Try to clean up any services that may have started
|
|
await this.stop();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log comprehensive startup summary
|
|
*/
|
|
private logStartupSummary(): void {
|
|
logger.log('info', 'DcRouter Started Successfully');
|
|
|
|
// Metrics summary
|
|
if (this.metricsManager) {
|
|
logger.log('info', 'Metrics Service: SmartMetrics active, SmartProxy stats active, real-time tracking enabled');
|
|
}
|
|
|
|
// SmartProxy summary
|
|
if (this.smartProxy) {
|
|
const routeCount = this.options.smartProxyConfig?.routes?.length || 0;
|
|
const acmeEnabled = this.options.smartProxyConfig?.acme?.enabled || false;
|
|
const acmeMode = acmeEnabled
|
|
? `email=${this.options.smartProxyConfig!.acme!.email || 'not set'}, mode=${this.options.smartProxyConfig!.acme!.useProduction ? 'production' : 'staging'}`
|
|
: 'disabled';
|
|
logger.log('info', `SmartProxy Service: ${routeCount} routes, ACME: ${acmeMode}`);
|
|
}
|
|
|
|
// Email service summary
|
|
if (this.emailServer && this.options.emailConfig) {
|
|
const ports = this.options.emailConfig.ports || [];
|
|
const domainCount = this.options.emailConfig.domains?.length || 0;
|
|
const domainNames = this.options.emailConfig.domains?.map(d => `${d.domain} (${d.dnsMode || 'default'})`).join(', ') || 'none';
|
|
logger.log('info', `Email Service: ports=[${ports.join(', ')}], hostname=${this.options.emailConfig.hostname || 'localhost'}, domains=${domainCount} [${domainNames}], DKIM initialized`);
|
|
}
|
|
|
|
// DNS service summary
|
|
if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) {
|
|
logger.log('info', `DNS Service: nameservers=[${this.options.dnsNsDomains.join(', ')}], authoritative for ${this.options.dnsScopes.length} domains [${this.options.dnsScopes.join(', ')}], UDP:53, DoH enabled`);
|
|
}
|
|
|
|
// RADIUS service summary
|
|
if (this.radiusServer && this.options.radiusConfig) {
|
|
const vlanStats = this.radiusServer.getVlanManager().getStats();
|
|
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
|
|
}
|
|
|
|
// Remote Ingress summary
|
|
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
|
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
|
const connectedCount = this.tunnelManager.getConnectedCount();
|
|
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
|
|
}
|
|
|
|
// Storage summary
|
|
if (this.storageManager && this.options.storage) {
|
|
logger.log('info', `Storage: path=${this.options.storage.fsPath || 'default'}`);
|
|
}
|
|
|
|
// Cache database summary
|
|
if (this.cacheDb) {
|
|
logger.log('info', `Cache Database: storage=${this.cacheDb.getStoragePath()}, db=${this.cacheDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
|
|
}
|
|
|
|
logger.log('info', 'All services are running');
|
|
}
|
|
|
|
/**
|
|
* Set up the cache database (smartdata + LocalTsmDb)
|
|
*/
|
|
private async setupCacheDb(): Promise<void> {
|
|
logger.log('info', 'Setting up CacheDb...');
|
|
|
|
const cacheConfig = this.options.cacheConfig || {};
|
|
|
|
// Initialize CacheDb singleton
|
|
this.cacheDb = CacheDb.getInstance({
|
|
storagePath: cacheConfig.storagePath || this.resolvedPaths.defaultTsmDbPath,
|
|
dbName: cacheConfig.dbName || 'dcrouter',
|
|
debug: false,
|
|
});
|
|
|
|
await this.cacheDb.start();
|
|
|
|
// Start the cache cleaner
|
|
const cleanupIntervalMs = (cacheConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
|
|
this.cacheCleaner = new CacheCleaner(this.cacheDb, {
|
|
intervalMs: cleanupIntervalMs,
|
|
verbose: false,
|
|
});
|
|
this.cacheCleaner.start();
|
|
|
|
logger.log('info', `CacheDb initialized at ${this.cacheDb.getStoragePath()}`);
|
|
}
|
|
|
|
/**
|
|
* Set up SmartProxy with direct configuration and automatic email routes
|
|
*/
|
|
private async setupSmartProxy(): Promise<void> {
|
|
logger.log('info', '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;
|
|
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
|
|
}
|
|
|
|
// If email config exists, automatically add email routes
|
|
if (this.options.emailConfig) {
|
|
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(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();
|
|
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(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) {
|
|
logger.log('info', '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);
|
|
}
|
|
|
|
// Cache constructor routes for RouteConfigManager
|
|
this.constructorRoutes = [...routes];
|
|
|
|
// If we have routes or need a basic SmartProxy instance, create it
|
|
if (routes.length > 0 || this.options.smartProxyConfig) {
|
|
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
|
|
|
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
|
|
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
|
|
|
|
// Create SmartProxy configuration
|
|
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
|
|
...this.options.smartProxyConfig,
|
|
routes,
|
|
acme: acmeConfig,
|
|
certStore: {
|
|
loadAll: async () => {
|
|
const keys = await this.storageManager.list('/proxy-certs/');
|
|
const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = [];
|
|
for (const key of keys) {
|
|
const data = await this.storageManager.getJSON(key);
|
|
if (data) {
|
|
certs.push(data);
|
|
loadedCertEntries.push({ domain: data.domain, publicKey: data.publicKey, validUntil: data.validUntil, validFrom: data.validFrom });
|
|
}
|
|
}
|
|
return certs;
|
|
},
|
|
save: async (domain: string, publicKey: string, privateKey: string, ca?: string) => {
|
|
let validUntil: number | undefined;
|
|
let validFrom: number | undefined;
|
|
try {
|
|
const x509 = new plugins.crypto.X509Certificate(publicKey);
|
|
validUntil = new Date(x509.validTo).getTime();
|
|
validFrom = new Date(x509.validFrom).getTime();
|
|
} catch { /* PEM parsing failed */ }
|
|
await this.storageManager.setJSON(`/proxy-certs/${domain}`, {
|
|
domain, publicKey, privateKey, ca, validUntil, validFrom,
|
|
});
|
|
},
|
|
remove: async (domain: string) => {
|
|
await this.storageManager.delete(`/proxy-certs/${domain}`);
|
|
},
|
|
},
|
|
};
|
|
|
|
// Initialize cert provision scheduler
|
|
this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager);
|
|
|
|
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
|
|
if (challengeHandlers.length > 0) {
|
|
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
|
|
if (this.smartAcme) {
|
|
await this.smartAcme.stop().catch(err =>
|
|
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
|
|
);
|
|
}
|
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
|
certManager: new StorageBackedCertManager(this.storageManager),
|
|
environment: 'production',
|
|
challengeHandlers: challengeHandlers,
|
|
challengePriority: ['dns-01'],
|
|
});
|
|
await this.smartAcme.start();
|
|
|
|
const scheduler = this.certProvisionScheduler;
|
|
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
|
|
// Check backoff before attempting provision
|
|
if (await scheduler.isInBackoff(domain)) {
|
|
const info = await scheduler.getBackoffInfo(domain);
|
|
const msg = `Domain ${domain} is in backoff (${info?.failures} failures), retry after ${info?.retryAfter}`;
|
|
eventComms.warn(msg);
|
|
throw new Error(msg);
|
|
}
|
|
|
|
try {
|
|
// smartacme v9 handles concurrency, per-domain dedup, and rate limiting internally
|
|
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
|
|
eventComms.setSource('smartacme-dns-01');
|
|
const isWildcardDomain = domain.startsWith('*.');
|
|
const cert = await this.smartAcme.getCertificateForDomain(domain, {
|
|
includeWildcard: !isWildcardDomain,
|
|
});
|
|
if (cert.validUntil) {
|
|
eventComms.setExpiryDate(new Date(cert.validUntil));
|
|
}
|
|
const result = {
|
|
id: cert.id,
|
|
domainName: cert.domainName,
|
|
created: cert.created,
|
|
validUntil: cert.validUntil,
|
|
privateKey: cert.privateKey,
|
|
publicKey: cert.publicKey,
|
|
csr: cert.csr,
|
|
};
|
|
|
|
// Success — clear any backoff
|
|
await scheduler.clearBackoff(domain);
|
|
return result;
|
|
} catch (err) {
|
|
// Record failure for backoff tracking
|
|
await scheduler.recordFailure(domain, err.message);
|
|
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`);
|
|
return 'http01';
|
|
}
|
|
};
|
|
}
|
|
|
|
// When remoteIngress is enabled, the hub binary forwards tunneled connections
|
|
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
|
|
if (this.options.remoteIngressConfig?.enabled) {
|
|
smartProxyConfig.acceptProxyProtocol = true;
|
|
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
|
}
|
|
|
|
// Create SmartProxy instance
|
|
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
|
|
|
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
|
|
|
|
// Set up event listeners
|
|
this.smartProxy.on('error', (err) => {
|
|
logger.log('error', `SmartProxy error: ${err.message}`, { stack: err.stack });
|
|
});
|
|
|
|
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
|
|
// Events are keyed by domain for domain-centric certificate tracking
|
|
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
|
logger.log('info', `Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
|
const routeNames = this.findRouteNamesForDomain(event.domain);
|
|
this.certificateStatusMap.set(event.domain, {
|
|
status: 'valid', routeNames,
|
|
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
|
source: event.source,
|
|
});
|
|
});
|
|
|
|
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
|
logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
|
const routeNames = this.findRouteNamesForDomain(event.domain);
|
|
this.certificateStatusMap.set(event.domain, {
|
|
status: 'valid', routeNames,
|
|
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
|
source: event.source,
|
|
});
|
|
});
|
|
|
|
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
|
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
|
|
const routeNames = this.findRouteNamesForDomain(event.domain);
|
|
this.certificateStatusMap.set(event.domain, {
|
|
status: 'failed', routeNames, error: event.error,
|
|
source: event.source,
|
|
});
|
|
});
|
|
|
|
// Start SmartProxy
|
|
logger.log('info', 'Starting SmartProxy...');
|
|
await this.smartProxy.start();
|
|
logger.log('info', 'SmartProxy started successfully');
|
|
|
|
// Populate certificateStatusMap for certs loaded from store at startup
|
|
for (const entry of loadedCertEntries) {
|
|
if (!this.certificateStatusMap.has(entry.domain)) {
|
|
const routeNames = this.findRouteNamesForDomain(entry.domain);
|
|
let expiryDate: string | undefined;
|
|
let issuedAt: string | undefined;
|
|
|
|
// Use validUntil/validFrom from stored proxy-certs data if available
|
|
if (entry.validUntil) {
|
|
expiryDate = new Date(entry.validUntil).toISOString();
|
|
}
|
|
if (entry.validFrom) {
|
|
issuedAt = new Date(entry.validFrom).toISOString();
|
|
}
|
|
|
|
// Try SmartAcme /certs/ metadata as secondary source
|
|
if (!expiryDate) {
|
|
try {
|
|
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
|
|
const certMeta = await this.storageManager.getJSON(`/certs/${cleanDomain}`);
|
|
if (certMeta?.validUntil) {
|
|
expiryDate = new Date(certMeta.validUntil).toISOString();
|
|
}
|
|
if (certMeta?.created && !issuedAt) {
|
|
issuedAt = new Date(certMeta.created).toISOString();
|
|
}
|
|
} catch { /* no metadata available */ }
|
|
}
|
|
|
|
// Fallback: parse X509 from PEM to get expiry
|
|
if (!expiryDate && entry.publicKey) {
|
|
try {
|
|
const x509 = new plugins.crypto.X509Certificate(entry.publicKey);
|
|
expiryDate = new Date(x509.validTo).toISOString();
|
|
if (!issuedAt) {
|
|
issuedAt = new Date(x509.validFrom).toISOString();
|
|
}
|
|
} catch { /* PEM parsing failed */ }
|
|
}
|
|
|
|
this.certificateStatusMap.set(entry.domain, {
|
|
status: 'valid',
|
|
routeNames,
|
|
expiryDate,
|
|
issuedAt,
|
|
source: 'cert-store',
|
|
});
|
|
}
|
|
}
|
|
if (loadedCertEntries.length > 0) {
|
|
logger.log('info', `Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`);
|
|
}
|
|
|
|
logger.log('info', `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 forward action to route to internal email server ports
|
|
const defaultPortMapping: Record<number, number> = {
|
|
25: 10025, // SMTP
|
|
587: 10587, // Submission
|
|
465: 10465 // SMTPS
|
|
};
|
|
|
|
const portMapping = this.options.emailPortConfig?.portMapping || defaultPortMapping;
|
|
const internalPort = portMapping[port] || port + 10000;
|
|
|
|
let action: any = {
|
|
type: 'forward',
|
|
targets: [{
|
|
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);
|
|
}
|
|
|
|
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 {
|
|
domain = domain.toLowerCase();
|
|
pattern = pattern.toLowerCase();
|
|
|
|
if (domain === pattern) return true;
|
|
|
|
// Routing-glob: *example.com matches example.com, sub.example.com, *.example.com
|
|
if (pattern.startsWith('*') && !pattern.startsWith('*.')) {
|
|
const baseDomain = pattern.slice(1); // *nevermind.cloud → nevermind.cloud
|
|
if (domain === baseDomain || domain === `*.${baseDomain}`) return true;
|
|
if (domain.endsWith(baseDomain) && domain.length > baseDomain.length) return true;
|
|
}
|
|
|
|
// Standard wildcard: *.example.com matches sub.example.com and example.com
|
|
if (pattern.startsWith('*.')) {
|
|
const suffix = pattern.slice(2);
|
|
if (domain === suffix) return true;
|
|
return domain.endsWith(suffix) && domain.length > suffix.length;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Find the first route name that matches a given domain
|
|
*/
|
|
private findRouteNameForDomain(domain: string): string | undefined {
|
|
if (!this.smartProxy) return undefined;
|
|
for (const route of this.smartProxy.routeManager.getRoutes()) {
|
|
if (!route.match.domains || !route.name) continue;
|
|
const routeDomains = Array.isArray(route.match.domains)
|
|
? route.match.domains
|
|
: [route.match.domains];
|
|
for (const pattern of routeDomains) {
|
|
if (this.isDomainMatch(domain, pattern)) return route.name;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Find ALL route names that match a given domain
|
|
*/
|
|
public findRouteNamesForDomain(domain: string): string[] {
|
|
if (!this.smartProxy) return [];
|
|
const names: string[] = [];
|
|
for (const route of this.smartProxy.routeManager.getRoutes()) {
|
|
if (!route.match.domains || !route.name) continue;
|
|
const routeDomains = Array.isArray(route.match.domains)
|
|
? route.match.domains
|
|
: [route.match.domains];
|
|
for (const pattern of routeDomains) {
|
|
if (this.isDomainMatch(domain, pattern)) {
|
|
names.push(route.name);
|
|
break; // This route already matched, no need to check other patterns
|
|
}
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Get the routes derived from constructor config (smartProxy + email + DNS).
|
|
* Used by RouteConfigManager as the "hardcoded" base.
|
|
*/
|
|
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
|
|
return this.constructorRoutes;
|
|
}
|
|
|
|
public async stop() {
|
|
logger.log('info', 'Stopping DcRouter services...');
|
|
|
|
// Flush pending DNS batch log
|
|
if (this.dnsBatchTimer) {
|
|
clearTimeout(this.dnsBatchTimer);
|
|
if (this.dnsBatchCount > 0) {
|
|
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited, final flush)`, { zone: 'dns' });
|
|
}
|
|
this.dnsBatchTimer = null;
|
|
this.dnsBatchCount = 0;
|
|
this.dnsLogWindow = [];
|
|
}
|
|
|
|
await this.opsServer.stop();
|
|
|
|
try {
|
|
// Stop all services in parallel for faster shutdown
|
|
await Promise.all([
|
|
// Stop cache cleaner if running
|
|
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
|
|
|
|
// Stop metrics manager if running
|
|
this.metricsManager ? this.metricsManager.stop().catch(err => logger.log('error', 'Error stopping MetricsManager', { error: String(err) })) : Promise.resolve(),
|
|
|
|
// Stop unified email server if running
|
|
this.emailServer ? this.emailServer.stop().catch(err => logger.log('error', 'Error stopping email server', { error: String(err) })) : Promise.resolve(),
|
|
|
|
// Stop SmartAcme if running
|
|
this.smartAcme ? this.smartAcme.stop().catch(err => logger.log('error', 'Error stopping SmartAcme', { error: String(err) })) : Promise.resolve(),
|
|
|
|
// Stop HTTP SmartProxy if running
|
|
this.smartProxy ? this.smartProxy.stop().catch(err => logger.log('error', 'Error stopping SmartProxy', { error: String(err) })) : Promise.resolve(),
|
|
|
|
// Stop DNS server if running
|
|
this.dnsServer ?
|
|
this.dnsServer.stop().catch(err => logger.log('error', 'Error stopping DNS server', { error: String(err) })) :
|
|
Promise.resolve(),
|
|
|
|
// Stop RADIUS server if running
|
|
this.radiusServer ?
|
|
this.radiusServer.stop().catch(err => logger.log('error', 'Error stopping RADIUS server', { error: String(err) })) :
|
|
Promise.resolve(),
|
|
|
|
// Stop Remote Ingress tunnel manager if running
|
|
this.tunnelManager ?
|
|
this.tunnelManager.stop().catch(err => logger.log('error', 'Error stopping TunnelManager', { error: String(err) })) :
|
|
Promise.resolve()
|
|
]);
|
|
|
|
// Stop cache database after other services (they may need it during shutdown)
|
|
if (this.cacheDb) {
|
|
await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
|
|
}
|
|
|
|
// Clear backoff cache in cert scheduler
|
|
if (this.certProvisionScheduler) {
|
|
this.certProvisionScheduler.clear();
|
|
}
|
|
|
|
// Allow GC of stopped services by nulling references
|
|
this.smartProxy = undefined;
|
|
this.emailServer = undefined;
|
|
this.dnsServer = undefined;
|
|
this.metricsManager = undefined;
|
|
this.cacheCleaner = undefined;
|
|
this.cacheDb = undefined;
|
|
this.tunnelManager = undefined;
|
|
this.radiusServer = undefined;
|
|
this.smartAcme = undefined;
|
|
this.certProvisionScheduler = undefined;
|
|
this.remoteIngressManager = undefined;
|
|
this.routeConfigManager = undefined;
|
|
this.apiTokenManager = undefined;
|
|
this.certificateStatusMap.clear();
|
|
|
|
logger.log('info', 'All DcRouter services stopped');
|
|
} catch (error) {
|
|
logger.log('error', 'Error during DcRouter shutdown', { error: String(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;
|
|
|
|
// Update routes on RemoteIngressManager so derived ports stay in sync
|
|
if (this.remoteIngressManager && config.routes) {
|
|
this.remoteIngressManager.setRoutes(config.routes as any[]);
|
|
}
|
|
|
|
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
|
await this.setupSmartProxy();
|
|
|
|
// Re-apply programmatic routes and overrides after SmartProxy restart
|
|
if (this.routeConfigManager) {
|
|
await this.routeConfigManager.initialize();
|
|
}
|
|
|
|
logger.log('info', '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
|
|
};
|
|
|
|
// Transform domains if they are provided as strings
|
|
let transformedDomains = this.options.emailConfig.domains;
|
|
if (transformedDomains && transformedDomains.length > 0) {
|
|
// Check if domains are strings (for backward compatibility)
|
|
if (typeof transformedDomains[0] === 'string') {
|
|
transformedDomains = (transformedDomains as any).map((domain: string) => ({
|
|
domain,
|
|
dnsMode: 'external-dns' as const,
|
|
dkim: {
|
|
selector: 'default',
|
|
keySize: 2048,
|
|
rotateKeys: false,
|
|
rotationInterval: 90
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Create config with mapped ports
|
|
const emailConfig: IUnifiedEmailServerOptions = {
|
|
...this.options.emailConfig,
|
|
domains: transformedDomains,
|
|
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();
|
|
|
|
// Wire delivery events to MetricsManager and logger
|
|
if (this.metricsManager && this.emailServer.deliverySystem) {
|
|
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
|
this.metricsManager.trackEmailReceived(item?.from);
|
|
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
|
});
|
|
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
|
this.metricsManager.trackEmailSent(item?.to);
|
|
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
|
});
|
|
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
|
this.metricsManager.trackEmailFailed(item?.to, error?.message);
|
|
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
|
});
|
|
}
|
|
if (this.metricsManager && this.emailServer) {
|
|
this.emailServer.on('bounceProcessed', () => {
|
|
this.metricsManager.trackEmailBounced();
|
|
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
|
});
|
|
}
|
|
|
|
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();
|
|
|
|
logger.log('info', '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.updateEmailRoutes(routes);
|
|
}
|
|
|
|
logger.log('info', `Email routes updated with ${routes.length} routes`);
|
|
}
|
|
|
|
/**
|
|
* Get statistics from all components
|
|
*/
|
|
public getStats(): any {
|
|
const stats: any = {
|
|
emailServer: this.emailServer?.getStats()
|
|
};
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* 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`);
|
|
|
|
// Wire DNS query events to MetricsManager and logger with adaptive rate limiting
|
|
if (this.metricsManager && this.dnsServer) {
|
|
const flushDnsBatch = () => {
|
|
if (this.dnsBatchCount > 0) {
|
|
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited)`, { zone: 'dns' });
|
|
this.dnsBatchCount = 0;
|
|
}
|
|
this.dnsBatchTimer = null;
|
|
};
|
|
|
|
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
|
|
// Metrics tracking
|
|
for (const question of event.questions) {
|
|
this.metricsManager.trackDnsQuery(
|
|
question.type,
|
|
question.name,
|
|
false,
|
|
event.responseTimeMs,
|
|
event.answered,
|
|
);
|
|
}
|
|
|
|
// Adaptive logging: individual logs up to 2/sec, then batch
|
|
const now = Date.now();
|
|
this.dnsLogWindow = this.dnsLogWindow.filter(t => now - t < 1000);
|
|
|
|
if (this.dnsLogWindow.length < 2) {
|
|
this.dnsLogWindow.push(now);
|
|
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
|
|
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
|
|
} else {
|
|
this.dnsBatchCount++;
|
|
if (!this.dnsBatchTimer) {
|
|
this.dnsBatchTimer = setTimeout(flushDnsBatch, 5000);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 = this.resolvedPaths.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 (public in smartmta)
|
|
const dkimCreator = this.emailServer.dkimCreator;
|
|
if (!dkimCreator) {
|
|
logger.log('warn', 'DKIMCreator not available, skipping DKIM initialization');
|
|
return;
|
|
}
|
|
|
|
// Ensure necessary directories exist
|
|
paths.ensureDataDirectories(this.resolvedPaths);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up Remote Ingress hub for edge tunnel connections
|
|
*/
|
|
private async setupRemoteIngress(): Promise<void> {
|
|
if (!this.options.remoteIngressConfig?.enabled) {
|
|
return;
|
|
}
|
|
|
|
logger.log('info', 'Setting up Remote Ingress hub...');
|
|
|
|
// Initialize the edge registration manager
|
|
this.remoteIngressManager = new RemoteIngressManager(this.storageManager);
|
|
await this.remoteIngressManager.initialize();
|
|
|
|
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
|
const currentRoutes = this.options.smartProxyConfig?.routes || [];
|
|
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
|
|
|
// Create and start the tunnel manager
|
|
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
|
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
|
|
targetHost: '127.0.0.1',
|
|
});
|
|
await this.tunnelManager.start();
|
|
|
|
const edgeCount = this.remoteIngressManager.getAllEdges().length;
|
|
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
|
|
}
|
|
|
|
/**
|
|
* Set up RADIUS server for network authentication
|
|
*/
|
|
private async setupRadiusServer(): Promise<void> {
|
|
if (!this.options.radiusConfig) {
|
|
return;
|
|
}
|
|
|
|
logger.log('info', 'Setting up RADIUS server...');
|
|
|
|
this.radiusServer = new RadiusServer(this.options.radiusConfig, this.storageManager);
|
|
await this.radiusServer.start();
|
|
|
|
logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`);
|
|
}
|
|
|
|
/**
|
|
* Update RADIUS configuration at runtime
|
|
*/
|
|
public async updateRadiusConfig(config: IRadiusServerConfig): Promise<void> {
|
|
// Stop existing RADIUS server if running
|
|
if (this.radiusServer) {
|
|
await this.radiusServer.stop();
|
|
this.radiusServer = undefined;
|
|
}
|
|
|
|
// Update configuration
|
|
this.options.radiusConfig = config;
|
|
|
|
// Start with new configuration
|
|
await this.setupRadiusServer();
|
|
|
|
logger.log('info', 'RADIUS configuration updated');
|
|
}
|
|
}
|
|
|
|
// Re-export email server types for convenience
|
|
export type { IUnifiedEmailServerOptions };
|
|
|
|
// Re-export RADIUS types for convenience
|
|
export type { IRadiusServerConfig };
|
|
|
|
export default DcRouter;
|