Compare commits

...

2 Commits

Author SHA1 Message Date
jkunz e6b3625256 v14.0.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m42s
2026-06-05 03:28:38 +00:00
jkunz 103680a3a0 fix(proxy-protocol): apply inbound PROXY protocol policies per listener 2026-06-05 03:17:37 +00:00
10 changed files with 229 additions and 46 deletions
+11
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "14.0.0",
"version": "14.0.1",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
+2 -2
View File
@@ -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",
+5 -5
View File
@@ -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
+75 -5
View File
@@ -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 () => {
+1 -1
View File
@@ -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
View File
@@ -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,
+5 -1
View File
@@ -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);
+1 -8
View File
@@ -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,
+1 -1
View File
@@ -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.'
}