@serve.zone/dcrouter 18.1.0 → 18.2.0
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 +2072 -1624
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +64 -2
- package/dist_ts/classes.dcrouter.js +187 -6
- package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +25 -0
- package/dist_ts/db/documents/classes.authentication-event.doc.js +273 -0
- package/dist_ts/db/documents/classes.email-traffic-bucket.doc.d.ts +25 -0
- package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +200 -0
- package/dist_ts/db/documents/index.d.ts +2 -0
- package/dist_ts/db/documents/index.js +3 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +67 -0
- package/dist_ts/email/classes.accepted-email-spool.js +245 -17
- package/dist_ts/email/classes.workapp-mail-manager.js +5 -2
- package/dist_ts/monitoring/classes.metricsmanager.d.ts +19 -0
- package/dist_ts/monitoring/classes.metricsmanager.js +240 -44
- package/dist_ts/opsserver/handlers/admin.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/admin.handler.js +61 -14
- package/dist_ts/opsserver/handlers/security.handler.js +21 -3
- package/dist_ts/opsserver/handlers/stats.handler.js +27 -10
- package/dist_ts/security/classes.authentication-event-manager.d.ts +35 -0
- package/dist_ts/security/classes.authentication-event-manager.js +185 -0
- package/dist_ts/security/index.d.ts +1 -0
- package/dist_ts/security/index.js +2 -1
- package/dist_ts_interfaces/data/stats.d.ts +16 -0
- package/dist_ts_migrations/index.js +74 -15
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/overview/ops-view-overview.js +3 -2
- package/dist_ts_web/elements/security/ops-view-security-authentication.js +20 -13
- package/dist_ts_web/elements/security/ops-view-security-overview.js +8 -11
- package/package.json +3 -3
- package/readme.md +24 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +230 -6
- package/ts/db/documents/classes.authentication-event.doc.ts +248 -0
- package/ts/db/documents/classes.email-traffic-bucket.doc.ts +161 -0
- package/ts/db/documents/index.ts +2 -0
- package/ts/email/classes.accepted-email-spool.ts +319 -21
- package/ts/email/classes.workapp-mail-manager.ts +4 -1
- package/ts/monitoring/classes.metricsmanager.ts +287 -50
- package/ts/opsserver/handlers/admin.handler.ts +74 -15
- package/ts/opsserver/handlers/security.handler.ts +23 -2
- package/ts/opsserver/handlers/stats.handler.ts +29 -9
- package/ts/security/classes.authentication-event-manager.ts +221 -0
- package/ts/security/index.ts +1 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/overview/ops-view-overview.ts +2 -1
- package/ts_web/elements/security/ops-view-security-authentication.ts +20 -13
- package/ts_web/elements/security/ops-view-security-overview.ts +7 -12
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { RemoteIngressHubLifecycle, RemoteIngressManager, TunnelManager, buildEd
|
|
|
31
31
|
import { VpnAccessResolver, VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
|
32
32
|
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
|
|
33
33
|
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
|
|
34
|
-
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter } from './security/index.js';
|
|
34
|
+
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter, AuthenticationEventManager } from './security/index.js';
|
|
35
35
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
|
36
36
|
import { applyDefaultInboundPolicy } from './email/inbound-policy.js';
|
|
37
37
|
import { DnsManager } from './dns/manager.dns.js';
|
|
@@ -49,6 +49,32 @@ import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings
|
|
|
49
49
|
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
|
|
50
50
|
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* dcrouter's superset of SmartMTA's SMTP TLS options.
|
|
54
|
+
*
|
|
55
|
+
* SmartMTA consumes PEM content (`certPem`/`keyPem`). Deployments configure
|
|
56
|
+
* certificate FILES, so dcrouter accepts the path form too and loads it eagerly
|
|
57
|
+
* at startup — a path that cannot be read fails startup rather than silently
|
|
58
|
+
* leaving the listener without TLS.
|
|
59
|
+
*/
|
|
60
|
+
export interface IDcRouterEmailTlsConfig extends NonNullable<IUnifiedEmailServerOptions['tls']> {
|
|
61
|
+
/** Path to the certificate chain PEM file. Requires keyPath. */
|
|
62
|
+
certPath?: string;
|
|
63
|
+
/** Path to the private key PEM file. Requires certPath. */
|
|
64
|
+
keyPath?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** dcrouter's email server options: SmartMTA's, with the path-shaped tls block. */
|
|
68
|
+
export type IDcRouterEmailConfig = Omit<IUnifiedEmailServerOptions, 'tls'> & {
|
|
69
|
+
tls?: IDcRouterEmailTlsConfig;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Whether an email configuration carries any SMTP AUTH credentials. */
|
|
73
|
+
const emailConfigHasAuth = (emailConfigArg: IUnifiedEmailServerOptions | undefined): boolean =>
|
|
74
|
+
!!emailConfigArg?.auth?.required
|
|
75
|
+
|| !!emailConfigArg?.auth?.users?.length
|
|
76
|
+
|| !!emailConfigArg?.auth?.accounts?.length;
|
|
77
|
+
|
|
52
78
|
export interface IDcRouterOptions {
|
|
53
79
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
|
54
80
|
baseDir?: string;
|
|
@@ -66,7 +92,7 @@ export interface IDcRouterOptions {
|
|
|
66
92
|
* Email server configuration
|
|
67
93
|
* This enables all email handling with pattern-based routing
|
|
68
94
|
*/
|
|
69
|
-
emailConfig?:
|
|
95
|
+
emailConfig?: IDcRouterEmailConfig;
|
|
70
96
|
|
|
71
97
|
/** SmartBucket configuration for durable SmartMTA queue and attachment blobs. */
|
|
72
98
|
emailBlobStorage?: ISmartMtaBlobStorageConfig;
|
|
@@ -277,6 +303,7 @@ export class DcRouter {
|
|
|
277
303
|
public emailServer?: UnifiedEmailServer;
|
|
278
304
|
public radiusServer?: RadiusServer;
|
|
279
305
|
public opsServer!: OpsServer;
|
|
306
|
+
private opsServerStopped = false;
|
|
280
307
|
public metricsManager?: MetricsManager;
|
|
281
308
|
private emailEventSubscriptions: Array<{
|
|
282
309
|
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
|
|
@@ -333,6 +360,7 @@ export class DcRouter {
|
|
|
333
360
|
public mailDnsSync: MailDnsSync;
|
|
334
361
|
public mailEdgeEligibility: MailEdgeEligibility;
|
|
335
362
|
public securityPolicyManager?: SecurityPolicyManager;
|
|
363
|
+
public authenticationEventManager: AuthenticationEventManager;
|
|
336
364
|
|
|
337
365
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
|
338
366
|
public detectedPublicIp: string | null = null;
|
|
@@ -386,6 +414,9 @@ export class DcRouter {
|
|
|
386
414
|
smartProxyConfig: coreTrafficConfig,
|
|
387
415
|
};
|
|
388
416
|
|
|
417
|
+
// Authentication handlers and metrics share this single bounded event owner.
|
|
418
|
+
this.authenticationEventManager = new AuthenticationEventManager(this);
|
|
419
|
+
|
|
389
420
|
// Capture smartmta's own log stream into the ops log buffer — without
|
|
390
421
|
// this, mailer bridge crashes and delivery failures inside smartmta are
|
|
391
422
|
// invisible to `getLogs` and postmortems.
|
|
@@ -429,11 +460,12 @@ export class DcRouter {
|
|
|
429
460
|
new plugins.taskbuffer.Service('OpsServer')
|
|
430
461
|
.critical()
|
|
431
462
|
.withStart(async () => {
|
|
463
|
+
this.opsServerStopped = false;
|
|
432
464
|
this.opsServer = new OpsServer(this);
|
|
433
465
|
await this.opsServer.start();
|
|
434
466
|
})
|
|
435
467
|
.withStop(async () => {
|
|
436
|
-
await this.
|
|
468
|
+
await this.stopOpsServer();
|
|
437
469
|
})
|
|
438
470
|
.withRetry({ maxRetries: 0 }),
|
|
439
471
|
);
|
|
@@ -480,11 +512,17 @@ export class DcRouter {
|
|
|
480
512
|
.withRetry({ maxRetries: 0 }),
|
|
481
513
|
);
|
|
482
514
|
|
|
483
|
-
// MetricsManager
|
|
515
|
+
// Keep the database alive through the MetricsManager's final persistence drain.
|
|
516
|
+
const metricsDependencies = ['OpsServer'];
|
|
517
|
+
if (this.options.dbConfig?.enabled !== false) {
|
|
518
|
+
metricsDependencies.push('DcRouterDb');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// MetricsManager: optional, depends on OpsServer and the database when enabled.
|
|
484
522
|
this.serviceManager.addService(
|
|
485
523
|
new plugins.taskbuffer.Service('MetricsManager')
|
|
486
524
|
.optional()
|
|
487
|
-
.dependsOn(
|
|
525
|
+
.dependsOn(...metricsDependencies)
|
|
488
526
|
.withStart(async () => {
|
|
489
527
|
this.metricsManager = new MetricsManager(this);
|
|
490
528
|
await this.metricsManager.start();
|
|
@@ -1611,6 +1649,10 @@ export class DcRouter {
|
|
|
1611
1649
|
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
|
1612
1650
|
source: event.source,
|
|
1613
1651
|
});
|
|
1652
|
+
// Renewals arrive here too. The SMTP listener holds its PEM material as
|
|
1653
|
+
// listener-level Rust configuration, so a renewed mail-hostname
|
|
1654
|
+
// certificate must be pushed or STARTTLS keeps serving the stale one.
|
|
1655
|
+
void this.reapplyEmailTlsMaterial(event.domain);
|
|
1614
1656
|
});
|
|
1615
1657
|
|
|
1616
1658
|
// Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'.
|
|
@@ -1820,6 +1862,17 @@ export class DcRouter {
|
|
|
1820
1862
|
return names;
|
|
1821
1863
|
}
|
|
1822
1864
|
|
|
1865
|
+
private async stopOpsServer(): Promise<void> {
|
|
1866
|
+
if (this.opsServerStopped) return;
|
|
1867
|
+
this.opsServerStopped = true;
|
|
1868
|
+
try {
|
|
1869
|
+
await this.opsServer?.stop();
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
this.opsServerStopped = false;
|
|
1872
|
+
throw error;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1823
1876
|
public async stop() {
|
|
1824
1877
|
logger.log('info', 'Stopping DcRouter services...');
|
|
1825
1878
|
|
|
@@ -1829,6 +1882,10 @@ export class DcRouter {
|
|
|
1829
1882
|
this.serviceSubjectSubscription = undefined;
|
|
1830
1883
|
}
|
|
1831
1884
|
|
|
1885
|
+
// Stop accepting authenticated requests before MetricsManager performs its
|
|
1886
|
+
// final durable authentication-event and email-traffic drain.
|
|
1887
|
+
await this.stopOpsServer();
|
|
1888
|
+
|
|
1832
1889
|
// ServiceManager handles reverse-dependency-ordered shutdown
|
|
1833
1890
|
await this.serviceManager.stop();
|
|
1834
1891
|
|
|
@@ -1928,6 +1985,10 @@ export class DcRouter {
|
|
|
1928
1985
|
storageManager: this.smartMtaBlobStorageManager,
|
|
1929
1986
|
};
|
|
1930
1987
|
|
|
1988
|
+
const emailTls = await this.resolveEmailTlsMaterial();
|
|
1989
|
+
const tlsTerminatedPorts = this.resolveEdgeTerminatedEmailPorts(portMapping);
|
|
1990
|
+
this.logEmailAuthTransportPosture(emailConfigHasAuth(baseEmailConfig), emailTls, mappedEmailPorts, tlsTerminatedPorts, mappedSecurePort);
|
|
1991
|
+
|
|
1931
1992
|
let emailConfig: IUnifiedEmailServerOptions = await this.smtpAccountManager.composeEmailConfig({
|
|
1932
1993
|
...this.options.emailConfig,
|
|
1933
1994
|
ports: mappedEmailPorts,
|
|
@@ -1935,6 +1996,7 @@ export class DcRouter {
|
|
|
1935
1996
|
dkimKeyProvisioning: 'caller-managed',
|
|
1936
1997
|
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
|
1937
1998
|
queue: queueOptions,
|
|
1999
|
+
...(emailTls ? { tls: { ...baseEmailConfig.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem } } : {}),
|
|
1938
2000
|
outbound: {
|
|
1939
2001
|
...baseEmailConfig.outbound,
|
|
1940
2002
|
connectionProxyProvider: (context) => this.mailEgressCoordinator.provideConnectionProxy(context),
|
|
@@ -1944,6 +2006,7 @@ export class DcRouter {
|
|
|
1944
2006
|
...(mappedSecurePort !== undefined
|
|
1945
2007
|
? { securePort: mappedSecurePort }
|
|
1946
2008
|
: {}),
|
|
2009
|
+
...(tlsTerminatedPorts.length > 0 ? { tlsTerminatedPorts } : {}),
|
|
1947
2010
|
recipientValidation: true,
|
|
1948
2011
|
proxyProtocol: {
|
|
1949
2012
|
...baseEmailConfig.smtp?.proxyProtocol,
|
|
@@ -2098,7 +2161,6 @@ export class DcRouter {
|
|
|
2098
2161
|
|
|
2099
2162
|
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
|
2100
2163
|
const envelope = getEnvelope(item);
|
|
2101
|
-
this.metricsManager!.trackEmailReceived(envelope.from);
|
|
2102
2164
|
updateQueueSize();
|
|
2103
2165
|
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
|
2104
2166
|
});
|
|
@@ -2152,6 +2214,168 @@ export class DcRouter {
|
|
|
2152
2214
|
this.mailDnsSync.requestSync('email server start');
|
|
2153
2215
|
}
|
|
2154
2216
|
|
|
2217
|
+
/**
|
|
2218
|
+
* Resolve the PEM material for the SMTP listener.
|
|
2219
|
+
*
|
|
2220
|
+
* SmartMTA consumes `tls.certPem`/`tls.keyPem`; a path-shaped `tls` block is
|
|
2221
|
+
* ignored, which leaves the Rust listener with no TLS material at all — no
|
|
2222
|
+
* STARTTLS on the plain submission ports. Resolution order matches the
|
|
2223
|
+
* RemoteIngress tunnel precedent: explicit paths, then the ACME cert store.
|
|
2224
|
+
*
|
|
2225
|
+
* Explicitly configured paths that cannot be read FAIL STARTUP. Silently
|
|
2226
|
+
* continuing without TLS is what produced a cleartext submission port in
|
|
2227
|
+
* production, so a broken explicit configuration must be impossible to miss.
|
|
2228
|
+
*/
|
|
2229
|
+
private async resolveEmailTlsMaterial(): Promise<{ certPem: string; keyPem: string; source: string } | undefined> {
|
|
2230
|
+
const tlsConfig = this.options.emailConfig?.tls as (IUnifiedEmailServerOptions['tls'] & {
|
|
2231
|
+
certPath?: string;
|
|
2232
|
+
keyPath?: string;
|
|
2233
|
+
}) | undefined;
|
|
2234
|
+
|
|
2235
|
+
if (tlsConfig?.certPem && tlsConfig?.keyPem) {
|
|
2236
|
+
return { certPem: tlsConfig.certPem, keyPem: tlsConfig.keyPem, source: 'inline PEM' };
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
if (tlsConfig?.certPath || tlsConfig?.keyPath) {
|
|
2240
|
+
if (!tlsConfig.certPath || !tlsConfig.keyPath) {
|
|
2241
|
+
throw new Error(
|
|
2242
|
+
'emailConfig.tls requires both certPath and keyPath when either is configured',
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
let certPem: string;
|
|
2246
|
+
let keyPem: string;
|
|
2247
|
+
try {
|
|
2248
|
+
certPem = await plugins.fs.promises.readFile(tlsConfig.certPath, 'utf8');
|
|
2249
|
+
keyPem = await plugins.fs.promises.readFile(tlsConfig.keyPath, 'utf8');
|
|
2250
|
+
} catch (error: unknown) {
|
|
2251
|
+
throw new Error(
|
|
2252
|
+
`Unable to read the configured SMTP TLS material (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath}): ${(error as Error).message}`,
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
if (!certPem.trim() || !keyPem.trim()) {
|
|
2256
|
+
throw new Error(
|
|
2257
|
+
`The configured SMTP TLS material is empty (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath})`,
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
logger.log('info', `SMTP TLS material loaded from configured paths (${tlsConfig.certPath})`);
|
|
2261
|
+
return { certPem, keyPem, source: `configured paths (${tlsConfig.certPath})` };
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
const mailHostname = this.options.emailConfig?.hostname;
|
|
2265
|
+
if (mailHostname) {
|
|
2266
|
+
try {
|
|
2267
|
+
const stored = await ProxyCertDoc.findByDomain(mailHostname);
|
|
2268
|
+
if (stored?.publicKey && stored?.privateKey) {
|
|
2269
|
+
logger.log('info', `SMTP TLS material loaded from the stored ACME certificate for ${mailHostname}`);
|
|
2270
|
+
return { certPem: stored.publicKey, keyPem: stored.privateKey, source: `stored ACME certificate for ${mailHostname}` };
|
|
2271
|
+
}
|
|
2272
|
+
} catch (error: unknown) {
|
|
2273
|
+
logger.log('warn', `Unable to read the stored certificate for the mail hostname ${mailHostname}: ${(error as Error).message}`);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
return undefined;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
/**
|
|
2281
|
+
* Push renewed TLS material to the running SMTP listener.
|
|
2282
|
+
*
|
|
2283
|
+
* Only reacts to the mail hostname, and only when explicit paths are NOT
|
|
2284
|
+
* configured — an operator-managed file pair is not superseded by an ACME
|
|
2285
|
+
* renewal for the same name. SmartMTA restarts just the Rust listener when
|
|
2286
|
+
* the PEM material actually changes.
|
|
2287
|
+
*/
|
|
2288
|
+
private async reapplyEmailTlsMaterial(domainArg: string): Promise<void> {
|
|
2289
|
+
const emailServer = this.emailServer;
|
|
2290
|
+
const mailHostname = this.options.emailConfig?.hostname;
|
|
2291
|
+
if (!emailServer || !mailHostname) return;
|
|
2292
|
+
if (domainArg.toLowerCase() !== mailHostname.toLowerCase()) return;
|
|
2293
|
+
const tlsConfig = this.options.emailConfig?.tls;
|
|
2294
|
+
if (tlsConfig?.certPath || tlsConfig?.keyPath) return;
|
|
2295
|
+
|
|
2296
|
+
try {
|
|
2297
|
+
const emailTls = await this.resolveEmailTlsMaterial();
|
|
2298
|
+
if (!emailTls) return;
|
|
2299
|
+
emailServer.updateOptions({
|
|
2300
|
+
tls: { ...this.options.emailConfig?.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem },
|
|
2301
|
+
});
|
|
2302
|
+
if (this.options.emailConfig) {
|
|
2303
|
+
this.options.emailConfig.tls = {
|
|
2304
|
+
...this.options.emailConfig.tls,
|
|
2305
|
+
certPem: emailTls.certPem,
|
|
2306
|
+
keyPem: emailTls.keyPem,
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
logger.log('info', `Pushed renewed SMTP TLS material for ${mailHostname} to the email listener`);
|
|
2310
|
+
} catch (error: unknown) {
|
|
2311
|
+
logger.log('error', `Unable to apply renewed SMTP TLS material for ${mailHostname}: ${(error as Error).message}`);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* Internal SMTP ports whose public leg is TLS-terminated by CoreTraffic.
|
|
2317
|
+
*
|
|
2318
|
+
* Those backend legs arrive as plaintext even though the client's channel was
|
|
2319
|
+
* encrypted, so without declaring them the AUTH-requires-encryption gate would
|
|
2320
|
+
* refuse authentication on the only encrypted submission port. Derived from
|
|
2321
|
+
* the generated route set rather than hardcoding 465, so it stays true when
|
|
2322
|
+
* emailPortConfig changes.
|
|
2323
|
+
*/
|
|
2324
|
+
private resolveEdgeTerminatedEmailPorts(portMapping: Record<number, number>): number[] {
|
|
2325
|
+
if (!this.options.emailConfig) return [];
|
|
2326
|
+
const terminatedPorts: number[] = [];
|
|
2327
|
+
for (const route of this.emailRouteBuilder.generateEmailRoutes(this.options.emailConfig)) {
|
|
2328
|
+
if ((route.action as { tls?: { mode?: string } }).tls?.mode !== 'terminate') continue;
|
|
2329
|
+
const publicPorts = Array.isArray(route.match?.ports) ? route.match.ports : [];
|
|
2330
|
+
for (const publicPort of publicPorts) {
|
|
2331
|
+
if (typeof publicPort !== 'number') continue;
|
|
2332
|
+
const internalPort = portMapping[publicPort] || publicPort + 10000;
|
|
2333
|
+
// The implicit-TLS securePort is terminated by smartmta itself.
|
|
2334
|
+
if (internalPort === this.options.emailConfig.smtp?.securePort) continue;
|
|
2335
|
+
if (!terminatedPorts.includes(internalPort)) {
|
|
2336
|
+
terminatedPorts.push(internalPort);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
return terminatedPorts;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* State the transport posture of every SMTP listener port at startup.
|
|
2345
|
+
*
|
|
2346
|
+
* AUTH is only offered on an encrypted transport, so an operator has to be
|
|
2347
|
+
* able to see at a glance which submission ports can actually authenticate —
|
|
2348
|
+
* missing certificate material silently disabling AUTH on 587 would otherwise
|
|
2349
|
+
* look like a client problem.
|
|
2350
|
+
*/
|
|
2351
|
+
private logEmailAuthTransportPosture(
|
|
2352
|
+
authConfigured: boolean,
|
|
2353
|
+
emailTls: { source: string } | undefined,
|
|
2354
|
+
mappedPorts: number[],
|
|
2355
|
+
tlsTerminatedPorts: number[],
|
|
2356
|
+
mappedSecurePort: number | undefined,
|
|
2357
|
+
): void {
|
|
2358
|
+
const authCapable: number[] = [];
|
|
2359
|
+
const cleartext: number[] = [];
|
|
2360
|
+
for (const port of mappedPorts) {
|
|
2361
|
+
if (port === mappedSecurePort || tlsTerminatedPorts.includes(port) || emailTls) {
|
|
2362
|
+
authCapable.push(port);
|
|
2363
|
+
} else {
|
|
2364
|
+
cleartext.push(port);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
logger.log(
|
|
2368
|
+
'info',
|
|
2369
|
+
`SMTP transport posture: TLS material=${emailTls ? emailTls.source : 'NONE'}, edge-terminated ports=[${tlsTerminatedPorts.join(', ') || 'none'}], implicit-TLS port=${mappedSecurePort ?? 'none'}, AUTH-capable ports=[${authCapable.join(', ') || 'none'}]`,
|
|
2370
|
+
);
|
|
2371
|
+
if (authConfigured && !emailTls && cleartext.length > 0) {
|
|
2372
|
+
logger.log(
|
|
2373
|
+
'error',
|
|
2374
|
+
`SMTP AUTH is configured but ports [${cleartext.join(', ')}] have no TLS material and no upstream TLS terminator — AUTH is refused there because credentials must never cross a cleartext channel. Configure emailConfig.tls.certPath/keyPath, or provision an ACME certificate for ${this.options.emailConfig?.hostname || 'the mail hostname'}.`,
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2155
2379
|
/**
|
|
2156
2380
|
* Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges).
|
|
2157
2381
|
*/
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
3
|
+
import type {
|
|
4
|
+
IAuthenticationEvent,
|
|
5
|
+
TAuthenticationFailureReason,
|
|
6
|
+
TAuthenticationSource,
|
|
7
|
+
TResolvedAuthenticationSource,
|
|
8
|
+
} from '../../../ts_interfaces/data/stats.js';
|
|
9
|
+
|
|
10
|
+
const DB_OPERATION_TIMEOUT_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
13
|
+
|
|
14
|
+
@plugins.smartdata.Collection(() => getDb())
|
|
15
|
+
export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
16
|
+
AuthenticationEventDoc,
|
|
17
|
+
AuthenticationEventDoc
|
|
18
|
+
> implements IAuthenticationEvent {
|
|
19
|
+
@plugins.smartdata.unI()
|
|
20
|
+
@plugins.smartdata.svDb()
|
|
21
|
+
public id!: string;
|
|
22
|
+
|
|
23
|
+
@plugins.smartdata.svDb()
|
|
24
|
+
public timestamp!: number;
|
|
25
|
+
|
|
26
|
+
@plugins.smartdata.svDb()
|
|
27
|
+
public username!: string;
|
|
28
|
+
|
|
29
|
+
@plugins.smartdata.svDb()
|
|
30
|
+
public userId?: string;
|
|
31
|
+
|
|
32
|
+
@plugins.smartdata.svDb()
|
|
33
|
+
public success!: boolean;
|
|
34
|
+
|
|
35
|
+
@plugins.smartdata.svDb()
|
|
36
|
+
public requestedAuthSource!: TAuthenticationSource;
|
|
37
|
+
|
|
38
|
+
@plugins.smartdata.svDb()
|
|
39
|
+
public resolvedAuthSource?: TResolvedAuthenticationSource;
|
|
40
|
+
|
|
41
|
+
@plugins.smartdata.svDb()
|
|
42
|
+
public failureReason?: TAuthenticationFailureReason;
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public toApiObject(): IAuthenticationEvent {
|
|
49
|
+
return {
|
|
50
|
+
id: this.id,
|
|
51
|
+
timestamp: this.timestamp,
|
|
52
|
+
username: this.username,
|
|
53
|
+
success: this.success,
|
|
54
|
+
requestedAuthSource: this.requestedAuthSource,
|
|
55
|
+
...(this.userId ? { userId: this.userId } : {}),
|
|
56
|
+
...(this.resolvedAuthSource ? { resolvedAuthSource: this.resolvedAuthSource } : {}),
|
|
57
|
+
...(this.failureReason ? { failureReason: this.failureReason } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private static async getNativeCollection() {
|
|
62
|
+
const smartdataCollection = (AuthenticationEventDoc as typeof AuthenticationEventDoc & {
|
|
63
|
+
collection: plugins.smartdata.SmartdataCollection<AuthenticationEventDoc>;
|
|
64
|
+
}).collection;
|
|
65
|
+
await smartdataCollection.init();
|
|
66
|
+
const probe = new AuthenticationEventDoc();
|
|
67
|
+
await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
|
|
68
|
+
await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
|
|
69
|
+
return smartdataCollection.mongoDbCollection;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static toPersistedFields(eventArg: IAuthenticationEvent): IAuthenticationEvent {
|
|
73
|
+
return {
|
|
74
|
+
id: eventArg.id,
|
|
75
|
+
timestamp: eventArg.timestamp,
|
|
76
|
+
username: eventArg.username,
|
|
77
|
+
success: eventArg.success,
|
|
78
|
+
requestedAuthSource: eventArg.requestedAuthSource,
|
|
79
|
+
...(eventArg.userId !== undefined ? { userId: eventArg.userId } : {}),
|
|
80
|
+
...(eventArg.resolvedAuthSource !== undefined
|
|
81
|
+
? { resolvedAuthSource: eventArg.resolvedAuthSource }
|
|
82
|
+
: {}),
|
|
83
|
+
...(eventArg.failureReason !== undefined ? { failureReason: eventArg.failureReason } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private static validateEvent(eventArg: IAuthenticationEvent): void {
|
|
88
|
+
if (
|
|
89
|
+
typeof eventArg.id !== 'string'
|
|
90
|
+
|| !eventArg.id.trim()
|
|
91
|
+
|| !Number.isSafeInteger(eventArg.timestamp)
|
|
92
|
+
|| eventArg.timestamp < 0
|
|
93
|
+
|| typeof eventArg.username !== 'string'
|
|
94
|
+
|| !eventArg.username.trim()
|
|
95
|
+
|| typeof eventArg.success !== 'boolean'
|
|
96
|
+
|| !['auto', 'local', 'idp.global'].includes(eventArg.requestedAuthSource)
|
|
97
|
+
) {
|
|
98
|
+
throw new Error('Invalid authentication event');
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
eventArg.userId !== undefined
|
|
102
|
+
&& (typeof eventArg.userId !== 'string' || !eventArg.userId.trim())
|
|
103
|
+
) {
|
|
104
|
+
throw new Error('Invalid authentication event user ID');
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
eventArg.resolvedAuthSource !== undefined
|
|
108
|
+
&& eventArg.resolvedAuthSource !== 'local'
|
|
109
|
+
&& eventArg.resolvedAuthSource !== 'idp.global'
|
|
110
|
+
) {
|
|
111
|
+
throw new Error('Invalid resolved authentication source');
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
eventArg.failureReason !== undefined
|
|
115
|
+
&& ![
|
|
116
|
+
'invalidCredentials',
|
|
117
|
+
'serviceUnavailable',
|
|
118
|
+
'identityIssuanceFailed',
|
|
119
|
+
'internalError',
|
|
120
|
+
].includes(eventArg.failureReason)
|
|
121
|
+
) {
|
|
122
|
+
throw new Error('Invalid authentication failure reason');
|
|
123
|
+
}
|
|
124
|
+
if (eventArg.success && eventArg.failureReason) {
|
|
125
|
+
throw new Error('Successful authentication events cannot carry a failure reason');
|
|
126
|
+
}
|
|
127
|
+
if (!eventArg.success && !eventArg.failureReason) {
|
|
128
|
+
throw new Error('Failed authentication events require a failure reason');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public static async upsertMany(eventsArg: IAuthenticationEvent[]): Promise<void> {
|
|
133
|
+
if (eventsArg.length === 0) return;
|
|
134
|
+
if (eventsArg.length > 500) {
|
|
135
|
+
throw new Error('AuthenticationEventDoc.upsertMany accepts at most 500 events');
|
|
136
|
+
}
|
|
137
|
+
for (const event of eventsArg) {
|
|
138
|
+
AuthenticationEventDoc.validateEvent(event);
|
|
139
|
+
}
|
|
140
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
141
|
+
await collection.bulkWrite(
|
|
142
|
+
eventsArg.map((event) => {
|
|
143
|
+
const timestamp = new Date(event.timestamp).toISOString();
|
|
144
|
+
const persistedEvent = AuthenticationEventDoc.toPersistedFields(event);
|
|
145
|
+
return {
|
|
146
|
+
updateOne: {
|
|
147
|
+
filter: { id: event.id },
|
|
148
|
+
update: {
|
|
149
|
+
$setOnInsert: {
|
|
150
|
+
...persistedEvent,
|
|
151
|
+
_createdAt: timestamp,
|
|
152
|
+
_updatedAt: timestamp,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
upsert: true,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}),
|
|
159
|
+
{ ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public static async getWindowSummary(
|
|
164
|
+
cutoffArg: number,
|
|
165
|
+
limitArg = 100,
|
|
166
|
+
): Promise<{
|
|
167
|
+
successes: number;
|
|
168
|
+
failures: number;
|
|
169
|
+
events: IAuthenticationEvent[];
|
|
170
|
+
}> {
|
|
171
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
172
|
+
throw new Error('AuthenticationEventDoc.getWindowSummary requires a valid cutoff');
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) {
|
|
175
|
+
throw new Error('AuthenticationEventDoc.getWindowSummary requires a limit from 1 to 500');
|
|
176
|
+
}
|
|
177
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
178
|
+
const selector = { timestamp: { $gte: cutoffArg } };
|
|
179
|
+
const cursor = collection
|
|
180
|
+
.find(selector, { timeoutMS: DB_OPERATION_TIMEOUT_MS })
|
|
181
|
+
.sort({ timestamp: -1, id: -1 })
|
|
182
|
+
.limit(limitArg);
|
|
183
|
+
try {
|
|
184
|
+
const [successes, failures, rows] = await Promise.all([
|
|
185
|
+
collection.countDocuments(
|
|
186
|
+
{ ...selector, success: true },
|
|
187
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
188
|
+
),
|
|
189
|
+
collection.countDocuments(
|
|
190
|
+
{ ...selector, success: false },
|
|
191
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
192
|
+
),
|
|
193
|
+
cursor.toArray(),
|
|
194
|
+
]);
|
|
195
|
+
return {
|
|
196
|
+
successes,
|
|
197
|
+
failures,
|
|
198
|
+
events: rows.map((row) => AuthenticationEventDoc
|
|
199
|
+
.createInstanceFromMongoDbNativeDoc(row as any)
|
|
200
|
+
.toApiObject()),
|
|
201
|
+
};
|
|
202
|
+
} finally {
|
|
203
|
+
await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public static async findExistingIds(idsArg: string[]): Promise<Set<string>> {
|
|
208
|
+
const ids = [...new Set(idsArg)];
|
|
209
|
+
if (ids.length === 0) return new Set();
|
|
210
|
+
if (ids.length > 500) {
|
|
211
|
+
throw new Error('AuthenticationEventDoc.findExistingIds accepts at most 500 ids');
|
|
212
|
+
}
|
|
213
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
214
|
+
const rows = await collection
|
|
215
|
+
.find(
|
|
216
|
+
{ id: { $in: ids } },
|
|
217
|
+
{ projection: { id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
218
|
+
)
|
|
219
|
+
.toArray();
|
|
220
|
+
return new Set(rows.map((row) => String(row.id)));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
public static async pruneBefore(cutoffArg: number): Promise<number> {
|
|
224
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
225
|
+
throw new Error('AuthenticationEventDoc.pruneBefore requires a valid cutoff');
|
|
226
|
+
}
|
|
227
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
228
|
+
let deletedCount = 0;
|
|
229
|
+
while (true) {
|
|
230
|
+
const rows = await collection
|
|
231
|
+
.find(
|
|
232
|
+
{ timestamp: { $lt: cutoffArg } },
|
|
233
|
+
{ projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
234
|
+
)
|
|
235
|
+
.sort({ timestamp: 1, _id: 1 })
|
|
236
|
+
.limit(500)
|
|
237
|
+
.toArray();
|
|
238
|
+
if (rows.length === 0) break;
|
|
239
|
+
const result = await collection.deleteMany(
|
|
240
|
+
{ _id: { $in: rows.map((row) => row._id) } },
|
|
241
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
242
|
+
);
|
|
243
|
+
deletedCount += result.deletedCount;
|
|
244
|
+
if (rows.length < 500) break;
|
|
245
|
+
}
|
|
246
|
+
return deletedCount;
|
|
247
|
+
}
|
|
248
|
+
}
|