@serve.zone/dcrouter 18.8.2 → 18.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@ import { MetricsManager, OpsEventManager } from './monitoring/index.js';
29
29
  import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
30
30
  import { RemoteIngressHubLifecycle, RemoteIngressManager, TunnelManager, buildEdgeOnlyDerivationRoutes } from './remoteingress/index.js';
31
31
  import { VpnAccessResolver, VpnManager, type IVpnManagerConfig } from './vpn/index.js';
32
- import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
32
+ import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes, collectAutoCertificateHostnames } from './config/index.js';
33
33
  import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
34
34
  import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter, AuthenticationEventManager } from './security/index.js';
35
35
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -731,9 +731,9 @@ export class DcRouter {
731
731
  .withStart(async () => {
732
732
  this.remoteIngressManager = new RemoteIngressManager();
733
733
  this.remoteIngressManager.setHubPublicIps([this.options.publicIp]);
734
- this.remoteIngressManager.setOnEdgesChanged((reason) => (
735
- this.mailDnsSync.requestEdgeEligibilityCheck(reason)
736
- ));
734
+ this.remoteIngressManager.setOnEdgesChanged((reason) => {
735
+ this.handleRemoteIngressEdgesChanged(reason);
736
+ });
737
737
  await this.remoteIngressManager.initialize();
738
738
  })
739
739
  .withStop(async () => {
@@ -845,7 +845,17 @@ export class DcRouter {
845
845
  );
846
846
  }
847
847
  },
848
- (preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
848
+ async (preparedRoutes) => {
849
+ const runtimeRoutes = [
850
+ ...buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
851
+ ...this.emailRouteBuilder.getRuntimeSmtpsHostnameRoutes(preparedRoutes || []),
852
+ ];
853
+ await this.assertGeneratedRuntimeCertificateOwnership(
854
+ runtimeRoutes,
855
+ 'Runtime route application',
856
+ );
857
+ return runtimeRoutes;
858
+ },
849
859
  (storedRoute: IRoute) => this.emailRouteBuilder.hydrateStoredRouteForRuntime(storedRoute),
850
860
  (routes) => this.routePolicyAugmenter.applyInboundProxyProtocolPolicies(routes),
851
861
  );
@@ -1360,10 +1370,18 @@ export class DcRouter {
1360
1370
  logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
1361
1371
  }
1362
1372
 
1373
+ const runtimeEmailRoutes = this.emailRouteBuilder.getRuntimeEmailRoutes(
1374
+ this.seedEmailRoutes as IDcRouterRouteConfig[],
1375
+ );
1376
+ await this.assertGeneratedRuntimeCertificateOwnership(
1377
+ runtimeEmailRoutes,
1378
+ 'SmartProxy bootstrap',
1379
+ );
1380
+
1363
1381
  // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
1364
1382
  let routes: plugins.smartproxy.IRouteConfig[] = [
1365
1383
  ...this.seedConfigRoutes,
1366
- ...this.emailRouteBuilder.getRuntimeEmailRoutes(this.seedEmailRoutes as IDcRouterRouteConfig[]),
1384
+ ...runtimeEmailRoutes,
1367
1385
  ...this.runtimeDnsRoutes,
1368
1386
  ];
1369
1387
 
@@ -1749,6 +1767,47 @@ export class DcRouter {
1749
1767
  }
1750
1768
  }
1751
1769
 
1770
+ private async assertGeneratedRuntimeCertificateOwnership(
1771
+ routes: plugins.smartproxy.IRouteConfig[],
1772
+ operation: string,
1773
+ ): Promise<void> {
1774
+ const hostnames = new Set<string>();
1775
+ for (const route of routes) {
1776
+ for (const hostname of collectAutoCertificateHostnames(route as IDcRouterRouteConfig)) {
1777
+ hostnames.add(hostname);
1778
+ }
1779
+ }
1780
+ if (hostnames.size === 0) {
1781
+ return;
1782
+ }
1783
+ if (!this.dnsManager) {
1784
+ throw new Error(
1785
+ `${operation} refused: DnsManager is unavailable, so generated certificate requirements cannot be verified`,
1786
+ );
1787
+ }
1788
+ for (const hostname of hostnames) {
1789
+ const ownership = await this.dnsManager.resolveDomainOwnership(hostname);
1790
+ if (!ownership.verified) {
1791
+ throw new DomainOwnershipError(
1792
+ ownership,
1793
+ operation,
1794
+ 'dcrouter-generated-route',
1795
+ { data: { domain: hostname } },
1796
+ );
1797
+ }
1798
+ }
1799
+ }
1800
+
1801
+ private handleRemoteIngressEdgesChanged(reason: string): void {
1802
+ this.mailDnsSync.requestEdgeEligibilityCheck(reason);
1803
+ this.routeConfigManager?.applyRoutes().catch((err: unknown) => {
1804
+ logger.log(
1805
+ 'error',
1806
+ `Failed to re-apply routes after Remote Ingress edge change (${reason}): ${(err as Error).message}`,
1807
+ );
1808
+ });
1809
+ }
1810
+
1752
1811
  public async applySecurityPolicy(): Promise<void> {
1753
1812
  if (!this.securityPolicyManager) {
1754
1813
  return;
@@ -55,6 +55,26 @@ export interface IUnverifiedRouteDomain {
55
55
  ownership: IDomainOwnershipUnverified;
56
56
  }
57
57
 
58
+ /** Hostnames for which a terminating route asks SmartProxy to obtain a certificate. */
59
+ export function collectAutoCertificateHostnames(route: IDcRouterRouteConfig): string[] {
60
+ const tls = route.action?.tls;
61
+ if (!tls || tls.mode === 'passthrough' || tls.certificate !== 'auto') {
62
+ return [];
63
+ }
64
+ const domains = route.match?.domains;
65
+ const entries = Array.isArray(domains)
66
+ ? domains
67
+ : typeof domains === 'string'
68
+ ? domains.split(',')
69
+ : [];
70
+ const hostnames = new Set<string>();
71
+ for (const entry of entries) {
72
+ const trimmed = typeof entry === 'string' ? entry.trim() : '';
73
+ if (trimmed) hostnames.add(trimmed);
74
+ }
75
+ return [...hostnames];
76
+ }
77
+
58
78
  interface IRouteMutationOptions {
59
79
  trustedManagedMutation?: boolean;
60
80
  replaceMetadata?: boolean;
@@ -103,7 +123,9 @@ export class RouteConfigManager {
103
123
  private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
104
124
  private referenceResolver?: ReferenceResolver,
105
125
  private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
106
- private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
126
+ private getRuntimeRoutes?: (
127
+ preparedRoutes?: plugins.smartproxy.IRouteConfig[],
128
+ ) => plugins.smartproxy.IRouteConfig[] | Promise<plugins.smartproxy.IRouteConfig[]>,
107
129
  private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
108
130
  private applyInboundProxyPolicies?: (routes: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
109
131
  ) {}
@@ -136,30 +158,6 @@ export class RouteConfigManager {
136
158
  // Certificate requirement / domain ownership gate
137
159
  // =========================================================================
138
160
 
139
- /**
140
- * Hostnames for which this route asks dcrouter to obtain a certificate.
141
- * `tls.certificate === 'auto'` on a terminating route is exactly what makes
142
- * SmartProxy call certProvisionFunction, i.e. what starts an ACME order.
143
- */
144
- private collectAutoCertificateHostnames(route: IDcRouterRouteConfig): string[] {
145
- const tls = route.action?.tls;
146
- if (!tls || tls.mode === 'passthrough' || tls.certificate !== 'auto') {
147
- return [];
148
- }
149
- const domains = route.match?.domains;
150
- const entries = Array.isArray(domains)
151
- ? domains
152
- : typeof domains === 'string'
153
- ? domains.split(',')
154
- : [];
155
- const hostnames = new Set<string>();
156
- for (const entry of entries) {
157
- const trimmed = typeof entry === 'string' ? entry.trim() : '';
158
- if (trimmed) hostnames.add(trimmed);
159
- }
160
- return [...hostnames];
161
- }
162
-
163
161
  /**
164
162
  * Refuse a route whose certificate requirement covers a domain we cannot prove
165
163
  * we own. This is the point of attribution: the operator making the change gets
@@ -170,7 +168,7 @@ export class RouteConfigManager {
170
168
  route: IDcRouterRouteConfig,
171
169
  operation: string,
172
170
  ): Promise<void> {
173
- const hostnames = this.collectAutoCertificateHostnames(route);
171
+ const hostnames = collectAutoCertificateHostnames(route);
174
172
  if (hostnames.length === 0) {
175
173
  return;
176
174
  }
@@ -209,7 +207,7 @@ export class RouteConfigManager {
209
207
  }
210
208
  const auditableRoutes = [...this.routes.values()].filter((storedRoute) => {
211
209
  return storedRoute.enabled
212
- && this.collectAutoCertificateHostnames(storedRoute.route).length > 0;
210
+ && collectAutoCertificateHostnames(storedRoute.route).length > 0;
213
211
  });
214
212
  if (auditableRoutes.length === 0) {
215
213
  return [];
@@ -219,7 +217,7 @@ export class RouteConfigManager {
219
217
  const authorityZones = this.domainOwnershipSource.getAuthorityZones();
220
218
  const findings: IUnverifiedRouteDomain[] = [];
221
219
  for (const storedRoute of auditableRoutes) {
222
- for (const hostname of this.collectAutoCertificateHostnames(storedRoute.route)) {
220
+ for (const hostname of collectAutoCertificateHostnames(storedRoute.route)) {
223
221
  const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones });
224
222
  if (ownership.verified) continue;
225
223
  findings.push({
@@ -1209,7 +1207,7 @@ export class RouteConfigManager {
1209
1207
 
1210
1208
  let enabledRoutes = this.getPreparedEnabledRoutesForApply();
1211
1209
 
1212
- const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
1210
+ const runtimeRoutes = await this.getRuntimeRoutes?.(enabledRoutes) || [];
1213
1211
  for (const route of runtimeRoutes) {
1214
1212
  enabledRoutes.push(this.prepareRouteForApply(route));
1215
1213
  }
@@ -1,6 +1,6 @@
1
1
  // Export validation tools only
2
2
  export * from './validator.js';
3
- export { RouteConfigManager } from './classes.route-config-manager.js';
3
+ export { RouteConfigManager, collectAutoCertificateHostnames } from './classes.route-config-manager.js';
4
4
  export { ApiTokenManager } from './classes.api-token-manager.js';
5
5
  export { GatewayClientManager } from './classes.gateway-client-manager.js';
6
6
  export { ReferenceResolver } from './classes.reference-resolver.js';
@@ -5,6 +5,7 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js';
5
5
  import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
6
6
  import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
7
7
  import type { DcRouter } from '../classes.dcrouter.js';
8
+ import { normalizeOwnershipHostname } from '../dns/domain-ownership.js';
8
9
 
9
10
  type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
10
11
 
@@ -121,7 +122,56 @@ export class EmailRouteBuilder {
121
122
  }
122
123
 
123
124
  public getRuntimeEmailRoutes(emailRoutes: IDcRouterRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
124
- return emailRoutes.map((route) => this.createServerFirstEmailRuntimeRoute(route) || route);
125
+ const runtimeRoutes = emailRoutes.map(
126
+ (route) => this.createServerFirstEmailRuntimeRoute(route) || route,
127
+ );
128
+ return [...runtimeRoutes, ...this.getRuntimeSmtpsHostnameRoutes(runtimeRoutes)];
129
+ }
130
+
131
+ /**
132
+ * Add a hostname-restricted SMTPS route for SNI and certificate provisioning,
133
+ * while retaining the unrestricted generated route for clients without SNI.
134
+ */
135
+ public getRuntimeSmtpsHostnameRoutes(
136
+ runtimeRoutes: plugins.smartproxy.IRouteConfig[],
137
+ ): plugins.smartproxy.IRouteConfig[] {
138
+ const namedRoutes = runtimeRoutes.filter((route) => route.name === 'smtps-route');
139
+ if (namedRoutes.length === 0) {
140
+ return [];
141
+ }
142
+ if (namedRoutes.length !== 1) {
143
+ throw new Error(`Expected exactly one generated smtps-route, found ${namedRoutes.length}`);
144
+ }
145
+
146
+ const fallbackRoute = namedRoutes[0];
147
+ const ports = plugins.smartproxy.expandPortRange(fallbackRoute.match.ports as any) as number[];
148
+ const action = fallbackRoute.action;
149
+ if (
150
+ ports.length !== 1
151
+ || ports[0] !== 465
152
+ || fallbackRoute.match.domains !== undefined
153
+ || (fallbackRoute.priority ?? 0) !== 0
154
+ || action.type !== 'socket-handler'
155
+ || typeof action.socketHandler !== 'function'
156
+ || action.tls?.mode !== 'terminate'
157
+ || action.tls?.certificate !== 'auto'
158
+ ) {
159
+ throw new Error(
160
+ 'Generated smtps-route no longer matches the guarded unrestricted TLS-terminating socket-handler shape',
161
+ );
162
+ }
163
+
164
+ return [{
165
+ ...fallbackRoute,
166
+ id: 'runtime-email-smtps-hostnames',
167
+ name: 'smtps-hostnames-route',
168
+ priority: 100,
169
+ match: {
170
+ ...fallbackRoute.match,
171
+ domains: this.getSmtpsCertificateHostnames(),
172
+ },
173
+ action: { ...fallbackRoute.action },
174
+ }];
125
175
  }
126
176
 
127
177
  /**
@@ -164,6 +214,30 @@ export class EmailRouteBuilder {
164
214
  return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]);
165
215
  }
166
216
 
217
+ private getSmtpsCertificateHostnames(): string[] {
218
+ const configuredHostname = this.dcRouterRef.options.emailConfig?.hostname;
219
+ if (typeof configuredHostname !== 'string' || !configuredHostname.trim()) {
220
+ throw new Error('SMTPS port 465 requires a non-empty configured email hostname');
221
+ }
222
+
223
+ const candidates = [
224
+ configuredHostname,
225
+ ...(this.dcRouterRef.remoteIngressManager?.getMailEdges() || [])
226
+ .filter((edge) => edge.enabled && edge.tags?.includes(REMOTE_INGRESS_MAIL_TAG))
227
+ .map((edge) => edge.mailHostname)
228
+ .filter((hostname): hostname is string => typeof hostname === 'string' && Boolean(hostname.trim())),
229
+ ];
230
+ const hostnames = new Set<string>();
231
+ for (const candidate of candidates) {
232
+ const normalized = candidate.trim().toLowerCase().replace(/\.$/, '');
233
+ if (normalizeOwnershipHostname(normalized) !== normalized) {
234
+ throw new Error(`SMTPS certificate hostname '${candidate}' is not a valid fully qualified domain name`);
235
+ }
236
+ hostnames.add(normalized);
237
+ }
238
+ return [...hostnames].sort();
239
+ }
240
+
167
241
  private shouldHydrateGeneratedEmailRoute(storedRoute: IRoute): boolean {
168
242
  if (storedRoute.origin !== 'email') {
169
243
  return false;
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.8.2',
6
+ version: '18.8.4',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }