@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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js.map +1 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -0
- package/dist_ts/classes.dcrouter.js +42 -5
- package/dist_ts/config/classes.route-config-manager.d.ts +3 -7
- package/dist_ts/config/classes.route-config-manager.js +25 -29
- package/dist_ts/config/index.d.ts +1 -1
- package/dist_ts/config/index.js +2 -2
- package/dist_ts/email/classes.email-route-builder.d.ts +6 -0
- package/dist_ts/email/classes.email-route-builder.js +63 -2
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +65 -6
- package/ts/config/classes.route-config-manager.ts +27 -29
- package/ts/config/index.ts +1 -1
- package/ts/email/classes.email-route-builder.ts +75 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -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.
|
|
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) =>
|
|
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
|
-
...
|
|
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?: (
|
|
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 =
|
|
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
|
-
&&
|
|
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
|
|
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
|
}
|
package/ts/config/index.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|