import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta'; import type { IRoute } from '../../ts_interfaces/data/route-management.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js'; import type { DcRouter } from '../classes.dcrouter.js'; import { normalizeOwnershipHostname } from '../dns/domain-ownership.js'; type TInboundProxyProtocolPolicy = NonNullable; /** * Generates SmartProxy routes for the email ports and hydrates persisted * email routes into runtime routes: server-first SMTP ports get a raw * socket-handler proxy that injects PROXY protocol toward the backend. */ export class EmailRouteBuilder { constructor(private dcRouterRef: DcRouter) {} public generateEmailRoutes(emailConfig: IUnifiedEmailServerOptions): IDcRouterRouteConfig[] { const emailRoutes: IDcRouterRouteConfig[] = []; // 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: 'terminate' | undefined; // Handle different email ports differently switch (port) { case 25: // SMTP routeName = 'smtp-route'; break; case 587: // Submission routeName = 'submission-route'; break; case 465: // SMTPS routeName = 'smtps-route'; tlsMode = 'terminate'; // SmartProxy owns public TLS; backend remains server-first SMTP break; default: routeName = `email-port-${port}-route`; // Check if we have specific settings for this port if (this.dcRouterRef.options.emailPortConfig?.portSettings && this.dcRouterRef.options.emailPortConfig.portSettings[port]) { const portSettings = this.dcRouterRef.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 = { 25: 10025, // SMTP 587: 10587, // Submission 465: 10465 // SMTPS }; const portMapping = this.dcRouterRef.options.emailPortConfig?.portMapping || defaultPortMapping; const internalPort = portMapping[port] || port + 10000; let action: any = { type: 'forward', sendProxyProtocol: true, targets: [{ host: 'localhost', // Forward to internal email server port: internalPort, sendProxyProtocol: true, }] }; // Plain SMTP/STARTTLS ports must not carry TLS metadata, or SmartProxy waits for TLS/SNI first. if (tlsMode === 'terminate') { action.tls = { mode: tlsMode, certificate: 'auto', }; } // Create the route configuration const routeConfig: IDcRouterRouteConfig = { name: routeName, match: { ports: [port], transport: 'tcp', }, action: action }; routeConfig.ingress = { directHub: !this.dcRouterRef.isRemoteIngressHubEnabled(), smartVpn: !this.dcRouterRef.isRemoteIngressHubEnabled(), }; if (this.dcRouterRef.isRemoteIngressHubEnabled()) { // Only mail-tagged edges bind SMTP ports — other edges may sit on hosts // where 25/465/587 belong to a different mail system. routeConfig.remoteIngress = { enabled: true, edgeFilter: [REMOTE_INGRESS_MAIL_TAG] }; const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port); if (inboundProxyProtocol) { routeConfig.match.inboundProxyProtocol = inboundProxyProtocol; } } // Add the route to our list emailRoutes.push(routeConfig); } return emailRoutes; } public getRuntimeEmailRoutes(emailRoutes: IDcRouterRouteConfig[]): plugins.smartproxy.IRouteConfig[] { const runtimeRoutes = emailRoutes.map( (route) => this.createServerFirstEmailRuntimeRoute(route) || route, ); return [...runtimeRoutes, ...this.getRuntimeSmtpsHostnameRoutes(runtimeRoutes)]; } /** * Add a hostname-restricted SMTPS route for SNI and certificate provisioning, * while retaining the unrestricted generated route for clients without SNI. */ public getRuntimeSmtpsHostnameRoutes( runtimeRoutes: plugins.smartproxy.IRouteConfig[], ): plugins.smartproxy.IRouteConfig[] { const namedRoutes = runtimeRoutes.filter((route) => route.name === 'smtps-route'); if (namedRoutes.length === 0) { return []; } if (namedRoutes.length !== 1) { throw new Error(`Expected exactly one generated smtps-route, found ${namedRoutes.length}`); } const fallbackRoute = namedRoutes[0]; const ports = plugins.smartproxy.expandPortRange(fallbackRoute.match.ports as any) as number[]; const action = fallbackRoute.action; if ( ports.length !== 1 || ports[0] !== 465 || fallbackRoute.match.domains !== undefined || (fallbackRoute.priority ?? 0) !== 0 || action.type !== 'socket-handler' || typeof action.socketHandler !== 'function' || action.tls?.mode !== 'terminate' || action.tls?.certificate !== 'auto' ) { throw new Error( 'Generated smtps-route no longer matches the guarded unrestricted TLS-terminating socket-handler shape', ); } return [{ ...fallbackRoute, id: 'runtime-email-smtps-hostnames', name: 'smtps-hostnames-route', priority: 100, match: { ...fallbackRoute.match, domains: this.getSmtpsCertificateHostnames(), }, action: { ...fallbackRoute.action }, }]; } /** * Hydrate a persisted route into its runtime form: generated email routes * get the server-first socket-handler treatment, DoH routes get the DNS * socket handler. Returns undefined when the stored route runs as-is. */ public hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined { const routeName = storedRoute.route.name || ''; const isDohRoute = storedRoute.origin === 'dns' && storedRoute.route.action?.type === 'socket-handler' && routeName.startsWith('dns-over-https-'); if (!isDohRoute) { if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) { return this.createServerFirstEmailRuntimeRoute(storedRoute.route); } return undefined; } return { ...storedRoute.route, action: { ...storedRoute.route.action, type: 'socket-handler' as any, socketHandler: this.dcRouterRef.dnsServerRuntime.createSocketHandler(), } as any, }; } private getCurrentGeneratedEmailRouteNames(): Set { if (this.dcRouterRef.options.dbConfig?.enabled === false) { return new Set(); } const sourceRoutes = this.dcRouterRef.seedEmailRoutes.length > 0 ? this.dcRouterRef.seedEmailRoutes : this.dcRouterRef.options.emailConfig ? this.generateEmailRoutes(this.dcRouterRef.options.emailConfig) : []; return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]); } private getSmtpsCertificateHostnames(): string[] { const configuredHostname = this.dcRouterRef.options.emailConfig?.hostname; if (typeof configuredHostname !== 'string' || !configuredHostname.trim()) { throw new Error('SMTPS port 465 requires a non-empty configured email hostname'); } const candidates = [ configuredHostname, ...(this.dcRouterRef.remoteIngressManager?.getMailEdges() || []) .filter((edge) => edge.enabled && edge.tags?.includes(REMOTE_INGRESS_MAIL_TAG)) .map((edge) => edge.mailHostname) .filter((hostname): hostname is string => typeof hostname === 'string' && Boolean(hostname.trim())), ]; const hostnames = new Set(); for (const candidate of candidates) { const normalized = candidate.trim().toLowerCase().replace(/\.$/, ''); if (normalizeOwnershipHostname(normalized) !== normalized) { throw new Error(`SMTPS certificate hostname '${candidate}' is not a valid fully qualified domain name`); } hostnames.add(normalized); } return [...hostnames].sort(); } private shouldHydrateGeneratedEmailRoute(storedRoute: IRoute): boolean { if (storedRoute.origin !== 'email') { return false; } const routeName = storedRoute.route.name; if (!routeName || !this.getCurrentGeneratedEmailRouteNames().has(routeName)) { return false; } const expectedSystemKey = `email:${routeName}`; return !storedRoute.systemKey || storedRoute.systemKey === expectedSystemKey; } private createServerFirstEmailRuntimeRoute( route: plugins.smartproxy.IRouteConfig, ): plugins.smartproxy.IRouteConfig | undefined { const action = route.action as any; if (action?.type !== 'forward') { return undefined; } const tlsMode = action.tls?.mode; if (tlsMode === 'terminate-and-reencrypt') { return undefined; } const routePorts = plugins.smartproxy.expandPortRange(route.match?.ports as any) as number[]; if (routePorts.length !== 1) { return undefined; } const target = action.targets?.[0]; if (!target || action.targets.length !== 1 || typeof target.port !== 'number') { return undefined; } if (typeof target.host !== 'string') { return undefined; } const targetHost = target.host === 'localhost' ? '127.0.0.1' : target.host; const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(routePorts[0]); return { ...route, match: { ...route.match, ...(inboundProxyProtocol ? { inboundProxyProtocol } : {}), }, action: { type: 'socket-handler' as any, ...(action.tls ? { tls: action.tls } : {}), socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port), } as any, }; } private getRemoteIngressEmailInboundProxyPolicy( port: number, ): TInboundProxyProtocolPolicy | undefined { if (!this.dcRouterRef.isRemoteIngressHubEnabled()) { return undefined; } return { mode: port === 25 || port === 587 ? 'required' : 'optional' }; } private createEmailSocketProxyHandler( targetHost: string, targetPort: number, ): NonNullable { return (clientSocket, context) => { let backendSocket: plugins.net.Socket | undefined; let connectTimeout: ReturnType & { unref?: () => void }; let cleanupDone = false; const cleanup = () => { if (cleanupDone) return; cleanupDone = true; clearTimeout(connectTimeout); clientSocket.removeListener('timeout', cleanup); clientSocket.removeListener('error', cleanup); clientSocket.removeListener('end', cleanup); clientSocket.removeListener('close', cleanup); backendSocket?.removeListener('timeout', cleanup); backendSocket?.removeListener('error', cleanup); backendSocket?.removeListener('end', cleanup); backendSocket?.removeListener('close', cleanup); clientSocket.destroy(); backendSocket?.destroy(); }; connectTimeout = setTimeout(() => { cleanup(); }, 30_000); connectTimeout.unref?.(); clientSocket.setTimeout(300_000); clientSocket.on('timeout', cleanup); clientSocket.on('error', cleanup); clientSocket.on('end', cleanup); clientSocket.on('close', cleanup); backendSocket = plugins.net.connect(targetPort, targetHost, () => { clearTimeout(connectTimeout); backendSocket?.setTimeout(300_000); const proxyHeader = this.createProxyProtocolV1Header( context?.clientIp, targetHost, 0, targetPort, ); if (!proxyHeader) { cleanup(); return; } backendSocket!.write(proxyHeader, () => { clientSocket.pipe(backendSocket!); backendSocket!.pipe(clientSocket); }); }); backendSocket.setTimeout(30_000); backendSocket.on('timeout', cleanup); backendSocket.on('error', cleanup); backendSocket.on('end', cleanup); backendSocket.on('close', cleanup); }; } private createProxyProtocolV1Header( sourceIp: string | undefined, destinationIp: string, sourcePort: number, destinationPort: number, ): string | undefined { if (!sourceIp || !plugins.net.isIP(sourceIp)) { logger.log('warn', `Cannot create email PROXY protocol header for invalid source IP: ${sourceIp || 'unknown'}`); return undefined; } const sourceFamily = plugins.net.isIP(sourceIp); const destinationAddress = destinationIp === 'localhost' || destinationIp === '127.0.0.1' || destinationIp === '::1' ? sourceFamily === 6 ? '::1' : '127.0.0.1' : destinationIp; const destinationFamily = plugins.net.isIP(destinationAddress); if (!destinationFamily) { logger.log('warn', `Cannot create email PROXY protocol header for invalid destination IP: ${destinationIp}`); return undefined; } if (sourceFamily !== destinationFamily) { return undefined; } const protocol = sourceFamily === 6 ? 'TCP6' : 'TCP4'; return `PROXY ${protocol} ${sourceIp} ${destinationAddress} ${sourcePort} ${destinationPort}\r\n`; } }