fix(email): relay server-first SMTP banners for generated email routes
This commit is contained in:
@@ -8,6 +8,13 @@
|
||||
|
||||
|
||||
|
||||
### Fixes
|
||||
|
||||
- relay server-first SMTP banners for generated email routes (email)
|
||||
- Convert generated plaintext email forward routes to runtime socket handlers for SmartProxy bootstrap.
|
||||
- Hydrate DB-backed generated email routes to the same runtime handlers when their email system keys match.
|
||||
- Add bidirectional socket proxy cleanup and tests for route hydration and SMTP banner relay.
|
||||
|
||||
## 2026-06-04 - 13.44.1
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import * as net from 'node:net';
|
||||
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
@@ -18,6 +19,68 @@ async function getFreePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function listen(server: net.Server, port: number = 0): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
const address = server.address();
|
||||
resolve(typeof address === 'object' && address ? address.port : port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function trackSocket(sockets: Set<net.Socket>, socket: net.Socket): void {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
}
|
||||
|
||||
async function closeServer(server: net.Server, sockets?: Set<net.Socket>): Promise<void> {
|
||||
for (const socket of sockets || []) {
|
||||
socket.destroy();
|
||||
}
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function readFirstSocketData(port: number): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const socket = net.connect({ host: '127.0.0.1', port });
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('end', onEnd);
|
||||
socket.removeListener('close', onClose);
|
||||
};
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
socket.destroy();
|
||||
callback();
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
settle(() => reject(new Error('Timed out waiting for socket data')));
|
||||
}, 5000) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
timeout.unref?.();
|
||||
const onData = (data: Buffer) => settle(() => resolve(data.toString('utf8')));
|
||||
const onError = (error: Error) => settle(() => reject(error));
|
||||
const onEnd = () => settle(() => reject(new Error('Socket ended before data')));
|
||||
const onClose = () => settle(() => reject(new Error('Socket closed before data')));
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('end', onEnd);
|
||||
socket.once('close', onClose);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
const customPortMapping: Record<number, number> = {
|
||||
@@ -109,6 +172,8 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
});
|
||||
expect(customPortRoute).toBeTruthy();
|
||||
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||
expect(customPortRoute?.action.type).toEqual('forward');
|
||||
expect(customPortRoute?.action.targets[0].host).toEqual('localhost');
|
||||
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
||||
expect(customPortRoute?.remoteIngress).toBeUndefined();
|
||||
|
||||
@@ -127,6 +192,89 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Generated plaintext email routes hydrate to server-first socket handlers', async () => {
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
const router = new DcRouter({ emailConfig });
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
|
||||
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
|
||||
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
|
||||
|
||||
const hydrate = (route: any, origin = 'email') => (router as any)['hydrateStoredRouteForRuntime']({
|
||||
id: `${origin}-${route.name}`,
|
||||
route,
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'system',
|
||||
origin,
|
||||
systemKey: `${origin}:${route.name}`,
|
||||
});
|
||||
|
||||
const runtimeSmtpRoute = hydrate(smtpRoute);
|
||||
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
const runtimeSubmissionRoute = hydrate(submissionRoute);
|
||||
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
expect(hydrate(smtpsRoute)).toBeUndefined();
|
||||
expect(hydrate(smtpRoute, 'api')).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
|
||||
const backendSockets = new Set<net.Socket>();
|
||||
const backend = net.createServer((socket) => {
|
||||
trackSocket(backendSockets, socket);
|
||||
socket.write('220 test.example ESMTP Service Ready\r\n');
|
||||
});
|
||||
const backendPort = await listen(backend);
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
const router = new DcRouter({
|
||||
emailConfig,
|
||||
emailPortConfig: {
|
||||
portMapping: { 2525: backendPort },
|
||||
},
|
||||
});
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
const route = routes.find((routeArg: any) => routeArg.name === 'email-port-2525-route');
|
||||
const runtimeRoute = (router as any)['createServerFirstEmailRuntimeRoute'](route);
|
||||
expect(runtimeRoute?.action.type).toEqual('socket-handler');
|
||||
|
||||
const frontendSockets = new Set<net.Socket>();
|
||||
const frontend = net.createServer((socket) => {
|
||||
trackSocket(frontendSockets, socket);
|
||||
runtimeRoute.action.socketHandler(socket, {
|
||||
port: 2525,
|
||||
clientIp: '127.0.0.1',
|
||||
serverIp: '127.0.0.1',
|
||||
routeName: route.name,
|
||||
timestamp: Date.now(),
|
||||
connectionId: 'test-email-proxy',
|
||||
});
|
||||
});
|
||||
const frontendPort = await listen(frontend);
|
||||
|
||||
try {
|
||||
const banner = await readFirstSocketData(frontendPort);
|
||||
expect(banner).toEqual('220 test.example ESMTP Service Ready\r\n');
|
||||
} finally {
|
||||
await closeServer(frontend, frontendSockets);
|
||||
await closeServer(backend, backendSockets);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465],
|
||||
|
||||
+113
-1
@@ -1172,7 +1172,7 @@ export class DcRouter {
|
||||
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [
|
||||
...this.seedConfigRoutes,
|
||||
...this.seedEmailRoutes,
|
||||
...this.getRuntimeEmailRoutes(this.seedEmailRoutes as IDcRouterRouteConfig[]),
|
||||
...this.runtimeDnsRoutes,
|
||||
];
|
||||
|
||||
@@ -1715,6 +1715,115 @@ export class DcRouter {
|
||||
return dnsRoutes;
|
||||
}
|
||||
|
||||
private getRuntimeEmailRoutes(emailRoutes: IDcRouterRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
|
||||
return emailRoutes.map((route) => this.createServerFirstEmailRuntimeRoute(route) || route);
|
||||
}
|
||||
|
||||
private getCurrentGeneratedEmailRouteNames(): Set<string> {
|
||||
const sourceRoutes = this.seedEmailRoutes.length > 0
|
||||
? this.seedEmailRoutes
|
||||
: this.options.emailConfig
|
||||
? this.generateEmailRoutes(this.options.emailConfig)
|
||||
: [];
|
||||
return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]);
|
||||
}
|
||||
|
||||
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' || 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;
|
||||
return {
|
||||
...route,
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
private createEmailSocketProxyHandler(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
|
||||
return (clientSocket) => {
|
||||
let backendSocket: plugins.net.Socket | undefined;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> & { 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);
|
||||
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 hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
|
||||
const routeName = storedRoute.route.name || '';
|
||||
const isDohRoute = storedRoute.origin === 'dns'
|
||||
@@ -1722,6 +1831,9 @@ export class DcRouter {
|
||||
&& routeName.startsWith('dns-over-https-');
|
||||
|
||||
if (!isDohRoute) {
|
||||
if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) {
|
||||
return this.createServerFirstEmailRuntimeRoute(storedRoute.route);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user