Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6b3625256 | |||
| 103680a3a0 |
@@ -3,6 +3,17 @@
|
||||
## Pending
|
||||
|
||||
|
||||
|
||||
## 2026-06-05 - 14.0.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- apply inbound PROXY protocol policies per listener (proxy-protocol)
|
||||
- Apply inbound PROXY protocol policies across prepared and runtime routes that share the same listener.
|
||||
- Require PROXY protocol for remote ingress SMTP and submission ports while using optional mode for other remote ingress and VPN listeners.
|
||||
- Trust localhost for remote ingress and VPN forwarding without globally enabling PROXY protocol.
|
||||
- Bump @push.rocks/smartproxy to ^27.12.8.
|
||||
|
||||
## 2026-06-04 - 14.0.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "14.0.0",
|
||||
"version": "14.0.1",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "14.0.0",
|
||||
"version": "14.0.1",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -61,7 +61,7 @@
|
||||
"@push.rocks/smartnetwork": "^4.7.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.12.7",
|
||||
"@push.rocks/smartproxy": "^27.12.8",
|
||||
"@push.rocks/smartradius": "^1.3.0",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
|
||||
Generated
+5
-5
@@ -84,8 +84,8 @@ importers:
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.12.7
|
||||
version: 27.12.7
|
||||
specifier: ^27.12.8
|
||||
version: 27.12.8
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
@@ -1429,8 +1429,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.4':
|
||||
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
|
||||
|
||||
'@push.rocks/smartproxy@27.12.7':
|
||||
resolution: {integrity: sha512-5QHQLNUqLn7wrMEP+X361aQSvc4p8RabgV9jPnx4G6DgR8a25Z4kN2PAgtsg75U9QyQbQicE2lyPqIPaSTQ+uQ==}
|
||||
'@push.rocks/smartproxy@27.12.8':
|
||||
resolution: {integrity: sha512-d1sbo2avzFO9PUXpb2FuBKwSDoackxNPFOHvR8q0DBMMoAmxRVf0mmhVxWrvqbGMk2N9rtORve2g3TsMJRTZYQ==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.6':
|
||||
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
|
||||
@@ -6581,7 +6581,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.4': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.12.7':
|
||||
'@push.rocks/smartproxy@27.12.8':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
|
||||
@@ -205,7 +205,7 @@ tap.test('DcRouter class - Generated plaintext email routes hydrate to server-fi
|
||||
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']({
|
||||
const hydrate = (routerArg: DcRouter, route: any, origin = 'email') => (routerArg as any)['hydrateStoredRouteForRuntime']({
|
||||
id: `${origin}-${route.name}`,
|
||||
route,
|
||||
enabled: true,
|
||||
@@ -216,16 +216,77 @@ tap.test('DcRouter class - Generated plaintext email routes hydrate to server-fi
|
||||
systemKey: `${origin}:${route.name}`,
|
||||
});
|
||||
|
||||
const runtimeSmtpRoute = hydrate(smtpRoute);
|
||||
const runtimeSmtpRoute = hydrate(router, smtpRoute);
|
||||
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
const runtimeSubmissionRoute = hydrate(submissionRoute);
|
||||
const runtimeSubmissionRoute = hydrate(router, submissionRoute);
|
||||
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
expect(hydrate(smtpsRoute)).toBeUndefined();
|
||||
expect(hydrate(smtpRoute, 'api')).toBeUndefined();
|
||||
expect(hydrate(router, smtpsRoute)).toBeUndefined();
|
||||
expect(hydrate(router, smtpRoute, 'api')).toBeUndefined();
|
||||
|
||||
const remoteIngressRouter = new DcRouter({
|
||||
emailConfig,
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
const staleSmtpRoute = {
|
||||
...smtpRoute,
|
||||
match: {
|
||||
...smtpRoute.match,
|
||||
inboundProxyProtocol: undefined,
|
||||
},
|
||||
};
|
||||
const runtimeRemoteSmtpRoute = hydrate(remoteIngressRouter, staleSmtpRoute);
|
||||
expect(runtimeRemoteSmtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Inbound PROXY policies are applied per listener', async () => {
|
||||
const router = new DcRouter({
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
const routes = (router as any)['applyInboundProxyProtocolPolicies']([{
|
||||
name: 'remote-route',
|
||||
match: { ports: [443], domains: ['remote.example.com'] },
|
||||
remoteIngress: { enabled: true },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
}, {
|
||||
name: 'same-listener-direct-route',
|
||||
match: { ports: [443], domains: ['direct.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9443 }],
|
||||
},
|
||||
}]);
|
||||
|
||||
expect(routes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
expect(routes[1].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
|
||||
const vpnRouter = new DcRouter({
|
||||
vpnConfig: { enabled: true },
|
||||
});
|
||||
const vpnRoutes = (vpnRouter as any)['applyInboundProxyProtocolPolicies']([{
|
||||
name: 'vpn-route',
|
||||
match: { ports: [9443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9443 }],
|
||||
},
|
||||
}]);
|
||||
|
||||
expect(vpnRoutes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
|
||||
@@ -297,6 +358,15 @@ tap.test('DcRouter class - Email routes are exposed through RemoteIngress when e
|
||||
for (const route of routes) {
|
||||
expect(route.remoteIngress).toEqual({ enabled: true });
|
||||
}
|
||||
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');
|
||||
expect(smtpRoute?.match.transport).toEqual('tcp');
|
||||
expect(smtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
expect(submissionRoute?.match.transport).toEqual('tcp');
|
||||
expect(submissionRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
expect(smtpsRoute?.action.type).toEqual('forward');
|
||||
expect(smtpsRoute?.match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '14.0.0',
|
||||
version: '14.0.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+127
-22
@@ -37,6 +37,8 @@ import type { IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed,
|
||||
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
baseDir?: string;
|
||||
@@ -647,6 +649,7 @@ export class DcRouter {
|
||||
},
|
||||
(preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
|
||||
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
|
||||
(routes) => this.applyInboundProxyProtocolPolicies(routes),
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
await this.apiTokenManager.initialize();
|
||||
@@ -1220,6 +1223,7 @@ export class DcRouter {
|
||||
routes = augmentRoutesWithHttp3(routes, http3Config);
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
routes = this.applyInboundProxyProtocolPolicies(routes);
|
||||
|
||||
const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
|
||||
const mergedSecurityPolicy = this.mergeSecurityPolicies(
|
||||
@@ -1379,27 +1383,12 @@ export class DcRouter {
|
||||
};
|
||||
}
|
||||
|
||||
// When remoteIngress is enabled, the hub binary forwards tunneled connections
|
||||
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
|
||||
if (this.isRemoteIngressHubEnabled()) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
}
|
||||
|
||||
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
// RemoteIngress and VPN forward through localhost with PROXY protocol.
|
||||
// SmartProxy only uses this as a trust list; routes still opt in per listener.
|
||||
if (this.isRemoteIngressHubEnabled() || this.options.vpnConfig?.enabled) {
|
||||
const trustedProxyIPs = new Set(smartProxyConfig.trustedProxyIPs || []);
|
||||
trustedProxyIPs.add('127.0.0.1');
|
||||
smartProxyConfig.trustedProxyIPs = [...trustedProxyIPs];
|
||||
}
|
||||
|
||||
// Create SmartProxy instance
|
||||
@@ -1576,6 +1565,101 @@ export class DcRouter {
|
||||
|
||||
|
||||
|
||||
private applyInboundProxyProtocolPolicies(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): plugins.smartproxy.IRouteConfig[] {
|
||||
const policiesByListener = new Map<string, TInboundProxyProtocolPolicy>();
|
||||
|
||||
for (const route of routes) {
|
||||
const policy = route.match?.inboundProxyProtocol || this.getDesiredInboundProxyProtocolPolicy(route);
|
||||
if (!policy) {
|
||||
continue;
|
||||
}
|
||||
for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
|
||||
const mergedPolicy = this.mergeInboundProxyProtocolPolicies(
|
||||
policiesByListener.get(listenerKey),
|
||||
policy,
|
||||
);
|
||||
if (mergedPolicy) {
|
||||
policiesByListener.set(listenerKey, mergedPolicy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (policiesByListener.size === 0) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
return routes.map((route) => {
|
||||
if (route.match?.inboundProxyProtocol) {
|
||||
return route;
|
||||
}
|
||||
let listenerPolicy: TInboundProxyProtocolPolicy | undefined;
|
||||
for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
|
||||
listenerPolicy = this.mergeInboundProxyProtocolPolicies(
|
||||
listenerPolicy,
|
||||
policiesByListener.get(listenerKey),
|
||||
);
|
||||
}
|
||||
if (!listenerPolicy) {
|
||||
return route;
|
||||
}
|
||||
return {
|
||||
...route,
|
||||
match: {
|
||||
...route.match,
|
||||
inboundProxyProtocol: listenerPolicy,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getDesiredInboundProxyProtocolPolicy(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
): TInboundProxyProtocolPolicy | undefined {
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (this.isRemoteIngressHubEnabled() && dcRoute.remoteIngress?.enabled) {
|
||||
const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
|
||||
if (ports.some((port) => port === 25 || port === 587)) {
|
||||
return { mode: 'required' };
|
||||
}
|
||||
return { mode: 'optional' };
|
||||
}
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
return { mode: 'optional' };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getInboundProxyListenerKeys(route: plugins.smartproxy.IRouteConfig): string[] {
|
||||
const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
|
||||
const transports = route.match.transport === 'udp'
|
||||
? ['udp']
|
||||
: route.match.transport === 'all'
|
||||
? ['tcp', 'udp']
|
||||
: ['tcp'];
|
||||
const keys: string[] = [];
|
||||
for (const port of ports) {
|
||||
for (const transport of transports) {
|
||||
keys.push(`${transport}:${port}`);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private mergeInboundProxyProtocolPolicies(
|
||||
current?: TInboundProxyProtocolPolicy,
|
||||
next?: TInboundProxyProtocolPolicy,
|
||||
): TInboundProxyProtocolPolicy | undefined {
|
||||
if (!current) return next;
|
||||
if (!next) return current;
|
||||
if (current.mode === 'required') return current;
|
||||
if (next.mode === 'required') return next;
|
||||
if (current.mode === 'optional') return current;
|
||||
if (next.mode === 'optional') return next;
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SmartProxy routes for email configuration
|
||||
*/
|
||||
@@ -1657,13 +1741,18 @@ export class DcRouter {
|
||||
const routeConfig: IDcRouterRouteConfig = {
|
||||
name: routeName,
|
||||
match: {
|
||||
ports: [port]
|
||||
ports: [port],
|
||||
transport: 'tcp',
|
||||
},
|
||||
action: action
|
||||
};
|
||||
|
||||
if (this.isRemoteIngressHubEnabled()) {
|
||||
routeConfig.remoteIngress = { enabled: true };
|
||||
const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port);
|
||||
if (inboundProxyProtocol) {
|
||||
routeConfig.match.inboundProxyProtocol = inboundProxyProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the route to our list
|
||||
@@ -1764,8 +1853,15 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
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,
|
||||
socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
|
||||
@@ -1773,6 +1869,15 @@ export class DcRouter {
|
||||
};
|
||||
}
|
||||
|
||||
private getRemoteIngressEmailInboundProxyPolicy(
|
||||
port: number,
|
||||
): TInboundProxyProtocolPolicy | undefined {
|
||||
if (!this.isRemoteIngressHubEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
return { mode: port === 25 || port === 587 ? 'required' : 'optional' };
|
||||
}
|
||||
|
||||
private createEmailSocketProxyHandler(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
|
||||
@@ -68,6 +68,7 @@ export class RouteConfigManager {
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
||||
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||
private applyInboundProxyPolicies?: (routes: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
||||
) {}
|
||||
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
@@ -714,12 +715,15 @@ export class RouteConfigManager {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
let enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
if (this.applyInboundProxyPolicies) {
|
||||
enabledRoutes = this.applyInboundProxyPolicies(enabledRoutes);
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
|
||||
@@ -39,14 +39,7 @@ export class ConfigHandler {
|
||||
? 'custom'
|
||||
: 'filesystem';
|
||||
|
||||
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||
let proxyIps = opts.proxyIps || [];
|
||||
if (proxyIps.length === 0 && dcRouter.smartProxy) {
|
||||
const spSettings = (dcRouter.smartProxy as any).settings;
|
||||
if (spSettings?.proxyIPs?.length > 0) {
|
||||
proxyIps = spSettings.proxyIPs;
|
||||
}
|
||||
}
|
||||
const proxyIps = opts.proxyIps || [];
|
||||
|
||||
const system: interfaces.requests.IConfigData['system'] = {
|
||||
baseDir: resolvedPaths.dcrouterHomeDir,
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '14.0.0',
|
||||
version: '14.0.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user