@serve.zone/dcrouter 18.0.2 → 18.1.1
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 +1529 -1382
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +7 -2
- package/dist_ts/classes.dcrouter.js +61 -9
- 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/classes.smtp-account.doc.d.ts +31 -0
- package/dist_ts/db/documents/classes.smtp-account.doc.js +176 -0
- package/dist_ts/db/documents/index.d.ts +3 -0
- package/dist_ts/db/documents/index.js +4 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -0
- package/dist_ts/email/classes.accepted-email-spool.js +9 -1
- package/dist_ts/email/classes.email-settings.manager.d.ts +7 -0
- package/dist_ts/email/classes.email-settings.manager.js +30 -1
- package/dist_ts/email/classes.smtp-account.manager.d.ts +110 -0
- package/dist_ts/email/classes.smtp-account.manager.js +507 -0
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +23 -4
- package/dist_ts/email/classes.workapp-mail-manager.js +39 -46
- package/dist_ts/email/index.d.ts +2 -0
- package/dist_ts/email/index.js +3 -1
- package/dist_ts/email/smtp-scram.d.ts +13 -0
- package/dist_ts/email/smtp-scram.js +19 -0
- package/dist_ts/monitoring/classes.metricsmanager.d.ts +19 -0
- package/dist_ts/monitoring/classes.metricsmanager.js +240 -44
- package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
- package/dist_ts/opsserver/classes.opsserver.js +3 -1
- 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/email-settings.handler.js +14 -2
- package/dist_ts/opsserver/handlers/index.d.ts +1 -0
- package/dist_ts/opsserver/handlers/index.js +2 -1
- package/dist_ts/opsserver/handlers/security.handler.js +21 -3
- package/dist_ts/opsserver/handlers/smtp-account.handler.d.ts +20 -0
- package/dist_ts/opsserver/handlers/smtp-account.handler.js +155 -0
- 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/email-settings.d.ts +10 -0
- package/dist_ts_interfaces/data/index.d.ts +1 -0
- package/dist_ts_interfaces/data/index.js +2 -1
- package/dist_ts_interfaces/data/route-management.d.ts +1 -1
- package/dist_ts_interfaces/data/route-management.js +3 -1
- package/dist_ts_interfaces/data/smtp-account.d.ts +50 -0
- package/dist_ts_interfaces/data/smtp-account.js +9 -0
- package/dist_ts_interfaces/data/stats.d.ts +16 -0
- package/dist_ts_interfaces/requests/index.d.ts +1 -0
- package/dist_ts_interfaces/requests/index.js +2 -1
- package/dist_ts_interfaces/requests/smtp-accounts.d.ts +125 -0
- package/dist_ts_interfaces/requests/smtp-accounts.js +2 -0
- package/dist_ts_migrations/index.js +186 -13
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/smtp-accounts.d.ts +38 -0
- package/dist_ts_web/appstate/smtp-accounts.js +94 -0
- package/dist_ts_web/appstate.d.ts +1 -0
- package/dist_ts_web/appstate.js +2 -1
- package/dist_ts_web/elements/email/index.d.ts +1 -0
- package/dist_ts_web/elements/email/index.js +2 -1
- package/dist_ts_web/elements/email/ops-view-smtp-accounts.d.ts +25 -0
- package/dist_ts_web/elements/email/ops-view-smtp-accounts.js +598 -0
- package/dist_ts_web/elements/ops-dashboard.js +3 -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/dist_ts_web/router.js +2 -2
- package/package.json +2 -2
- package/readme.md +20 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +66 -8
- 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/classes.smtp-account.doc.ts +103 -0
- package/ts/db/documents/index.ts +3 -0
- package/ts/email/classes.accepted-email-spool.ts +8 -0
- package/ts/email/classes.email-settings.manager.ts +34 -0
- package/ts/email/classes.smtp-account.manager.ts +601 -0
- package/ts/email/classes.workapp-mail-manager.ts +53 -55
- package/ts/email/index.ts +2 -0
- package/ts/email/smtp-scram.ts +24 -0
- package/ts/monitoring/classes.metricsmanager.ts +287 -50
- package/ts/opsserver/classes.opsserver.ts +2 -0
- package/ts/opsserver/handlers/admin.handler.ts +74 -15
- package/ts/opsserver/handlers/email-settings.handler.ts +13 -1
- package/ts/opsserver/handlers/index.ts +1 -0
- package/ts/opsserver/handlers/security.handler.ts +23 -2
- package/ts/opsserver/handlers/smtp-account.handler.ts +199 -0
- 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/appstate/smtp-accounts.ts +143 -0
- package/ts_web/appstate.ts +1 -0
- package/ts_web/elements/email/index.ts +1 -0
- package/ts_web/elements/email/ops-view-smtp-accounts.ts +586 -0
- package/ts_web/elements/ops-dashboard.ts +2 -0
- 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_web/router.ts +1 -1
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';
|
|
@@ -42,7 +42,7 @@ import { DomainOwnershipError } from './dns/domain-ownership.js';
|
|
|
42
42
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
|
43
43
|
import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
|
|
44
44
|
import { AcmePermanentFailureError, classifyAcmeFailure } from './acme/acme-failure-classification.js';
|
|
45
|
-
import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
|
|
45
|
+
import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, SmtpAccountManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
|
|
46
46
|
import { WebPushManager } from './webpush/index.js';
|
|
47
47
|
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
|
48
48
|
import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
|
|
@@ -277,6 +277,7 @@ export class DcRouter {
|
|
|
277
277
|
public emailServer?: UnifiedEmailServer;
|
|
278
278
|
public radiusServer?: RadiusServer;
|
|
279
279
|
public opsServer!: OpsServer;
|
|
280
|
+
private opsServerStopped = false;
|
|
280
281
|
public metricsManager?: MetricsManager;
|
|
281
282
|
private emailEventSubscriptions: Array<{
|
|
282
283
|
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
|
|
@@ -326,11 +327,14 @@ export class DcRouter {
|
|
|
326
327
|
public emailSettingsManager?: EmailSettingsManager;
|
|
327
328
|
public emailDomainManager?: EmailDomainManager;
|
|
328
329
|
public workAppMailManager: WorkAppMailManager;
|
|
330
|
+
/** Single composition owner of the email runtime auth block and route list. */
|
|
331
|
+
public smtpAccountManager: SmtpAccountManager;
|
|
329
332
|
public acceptedEmailSpool: AcceptedEmailSpool;
|
|
330
333
|
public mailEgressCoordinator: MailEgressCoordinator;
|
|
331
334
|
public mailDnsSync: MailDnsSync;
|
|
332
335
|
public mailEdgeEligibility: MailEdgeEligibility;
|
|
333
336
|
public securityPolicyManager?: SecurityPolicyManager;
|
|
337
|
+
public authenticationEventManager: AuthenticationEventManager;
|
|
334
338
|
|
|
335
339
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
|
336
340
|
public detectedPublicIp: string | null = null;
|
|
@@ -384,6 +388,9 @@ export class DcRouter {
|
|
|
384
388
|
smartProxyConfig: coreTrafficConfig,
|
|
385
389
|
};
|
|
386
390
|
|
|
391
|
+
// Authentication handlers and metrics share this single bounded event owner.
|
|
392
|
+
this.authenticationEventManager = new AuthenticationEventManager(this);
|
|
393
|
+
|
|
387
394
|
// Capture smartmta's own log stream into the ops log buffer — without
|
|
388
395
|
// this, mailer bridge crashes and delivery failures inside smartmta are
|
|
389
396
|
// invisible to `getLogs` and postmortems.
|
|
@@ -392,6 +399,7 @@ export class DcRouter {
|
|
|
392
399
|
// Resolve all data paths from baseDir
|
|
393
400
|
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
|
394
401
|
this.workAppMailManager = new WorkAppMailManager(this);
|
|
402
|
+
this.smtpAccountManager = new SmtpAccountManager(this);
|
|
395
403
|
this.acceptedEmailSpool = new AcceptedEmailSpool(this);
|
|
396
404
|
const mailEgressIdentitySource = new ReleasedRemoteIngressEgressIdentitySource(this);
|
|
397
405
|
this.mailEdgeEligibility = new MailEdgeEligibility(this, mailEgressIdentitySource);
|
|
@@ -426,11 +434,12 @@ export class DcRouter {
|
|
|
426
434
|
new plugins.taskbuffer.Service('OpsServer')
|
|
427
435
|
.critical()
|
|
428
436
|
.withStart(async () => {
|
|
437
|
+
this.opsServerStopped = false;
|
|
429
438
|
this.opsServer = new OpsServer(this);
|
|
430
439
|
await this.opsServer.start();
|
|
431
440
|
})
|
|
432
441
|
.withStop(async () => {
|
|
433
|
-
await this.
|
|
442
|
+
await this.stopOpsServer();
|
|
434
443
|
})
|
|
435
444
|
.withRetry({ maxRetries: 0 }),
|
|
436
445
|
);
|
|
@@ -477,11 +486,17 @@ export class DcRouter {
|
|
|
477
486
|
.withRetry({ maxRetries: 0 }),
|
|
478
487
|
);
|
|
479
488
|
|
|
480
|
-
// MetricsManager
|
|
489
|
+
// Keep the database alive through the MetricsManager's final persistence drain.
|
|
490
|
+
const metricsDependencies = ['OpsServer'];
|
|
491
|
+
if (this.options.dbConfig?.enabled !== false) {
|
|
492
|
+
metricsDependencies.push('DcRouterDb');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// MetricsManager: optional, depends on OpsServer and the database when enabled.
|
|
481
496
|
this.serviceManager.addService(
|
|
482
497
|
new plugins.taskbuffer.Service('MetricsManager')
|
|
483
498
|
.optional()
|
|
484
|
-
.dependsOn(
|
|
499
|
+
.dependsOn(...metricsDependencies)
|
|
485
500
|
.withStart(async () => {
|
|
486
501
|
this.metricsManager = new MetricsManager(this);
|
|
487
502
|
await this.metricsManager.start();
|
|
@@ -583,6 +598,26 @@ export class DcRouter {
|
|
|
583
598
|
);
|
|
584
599
|
}
|
|
585
600
|
|
|
601
|
+
// SmtpAccountManager: optional, depends on DcRouterDb — loads operator
|
|
602
|
+
// SMTP accounts into memory before EmailServer composes the auth block.
|
|
603
|
+
// The instance itself is constructed unconditionally in the constructor
|
|
604
|
+
// (composition must work with zero accounts when the DB is disabled);
|
|
605
|
+
// this service only manages the DB load.
|
|
606
|
+
if (this.options.dbConfig?.enabled !== false) {
|
|
607
|
+
this.serviceManager.addService(
|
|
608
|
+
new plugins.taskbuffer.Service('SmtpAccountManager')
|
|
609
|
+
.optional()
|
|
610
|
+
.dependsOn('DcRouterDb')
|
|
611
|
+
.withStart(async () => {
|
|
612
|
+
await this.smtpAccountManager.start();
|
|
613
|
+
})
|
|
614
|
+
.withStop(async () => {
|
|
615
|
+
await this.smtpAccountManager.stop();
|
|
616
|
+
})
|
|
617
|
+
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
586
621
|
// Email Domain Manager: optional, depends on DcRouterDb
|
|
587
622
|
if (this.options.dbConfig?.enabled !== false) {
|
|
588
623
|
this.serviceManager.addService(
|
|
@@ -849,6 +884,8 @@ export class DcRouter {
|
|
|
849
884
|
if (this.options.dbConfig?.enabled !== false) {
|
|
850
885
|
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
|
851
886
|
emailServiceDeps.push('EmailDomainManager');
|
|
887
|
+
// Accounts must be loaded before setupUnifiedEmailHandling composes auth.
|
|
888
|
+
emailServiceDeps.push('SmtpAccountManager');
|
|
852
889
|
// Explicit dependency: the outbound mode must come from the DB-backed settings;
|
|
853
890
|
// without it, deliveries defer via the coordinator's strict mode resolution
|
|
854
891
|
// instead of going direct.
|
|
@@ -1795,6 +1832,17 @@ export class DcRouter {
|
|
|
1795
1832
|
return names;
|
|
1796
1833
|
}
|
|
1797
1834
|
|
|
1835
|
+
private async stopOpsServer(): Promise<void> {
|
|
1836
|
+
if (this.opsServerStopped) return;
|
|
1837
|
+
this.opsServerStopped = true;
|
|
1838
|
+
try {
|
|
1839
|
+
await this.opsServer?.stop();
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
this.opsServerStopped = false;
|
|
1842
|
+
throw error;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1798
1846
|
public async stop() {
|
|
1799
1847
|
logger.log('info', 'Stopping DcRouter services...');
|
|
1800
1848
|
|
|
@@ -1804,6 +1852,10 @@ export class DcRouter {
|
|
|
1804
1852
|
this.serviceSubjectSubscription = undefined;
|
|
1805
1853
|
}
|
|
1806
1854
|
|
|
1855
|
+
// Stop accepting authenticated requests before MetricsManager performs its
|
|
1856
|
+
// final durable authentication-event and email-traffic drain.
|
|
1857
|
+
await this.stopOpsServer();
|
|
1858
|
+
|
|
1807
1859
|
// ServiceManager handles reverse-dependency-ordered shutdown
|
|
1808
1860
|
await this.serviceManager.stop();
|
|
1809
1861
|
|
|
@@ -1903,7 +1955,7 @@ export class DcRouter {
|
|
|
1903
1955
|
storageManager: this.smartMtaBlobStorageManager,
|
|
1904
1956
|
};
|
|
1905
1957
|
|
|
1906
|
-
let emailConfig: IUnifiedEmailServerOptions = await this.
|
|
1958
|
+
let emailConfig: IUnifiedEmailServerOptions = await this.smtpAccountManager.composeEmailConfig({
|
|
1907
1959
|
...this.options.emailConfig,
|
|
1908
1960
|
ports: mappedEmailPorts,
|
|
1909
1961
|
domains: applyDefaultInboundPolicy(this.options.emailConfig.domains),
|
|
@@ -1969,7 +2021,7 @@ export class DcRouter {
|
|
|
1969
2021
|
repairResult.failedDomainIds.length > 0
|
|
1970
2022
|
|| repairResult.failedConfiguredDomains.length > 0
|
|
1971
2023
|
)) {
|
|
1972
|
-
emailConfig = await this.
|
|
2024
|
+
emailConfig = await this.smtpAccountManager.composeEmailConfig({
|
|
1973
2025
|
...emailConfig,
|
|
1974
2026
|
domains: applyDefaultInboundPolicy(this.options.emailConfig?.domains),
|
|
1975
2027
|
});
|
|
@@ -2003,6 +2055,13 @@ export class DcRouter {
|
|
|
2003
2055
|
this.addEmailEventSubscription(emailServer, 'bridgeRecovered', () => {
|
|
2004
2056
|
logger.log('info', 'Mailer bridge recovered after restart');
|
|
2005
2057
|
});
|
|
2058
|
+
|
|
2059
|
+
// Live AUTH toggles restart only the SMTP listener; a failed restart
|
|
2060
|
+
// leaves SMTP down while the rest of the server runs. Surface it loudly —
|
|
2061
|
+
// isSmtpListenerActive() stays false until recovery.
|
|
2062
|
+
this.addEmailEventSubscription(emailServer, 'smtpListenerRestartFailed', (err: unknown) => {
|
|
2063
|
+
logger.log('error', `SMTP listener restart failed after an auth update — inbound SMTP is down until recovery: ${(err as Error)?.message || err}`);
|
|
2064
|
+
});
|
|
2006
2065
|
|
|
2007
2066
|
// Start the server
|
|
2008
2067
|
try {
|
|
@@ -2066,7 +2125,6 @@ export class DcRouter {
|
|
|
2066
2125
|
|
|
2067
2126
|
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
|
2068
2127
|
const envelope = getEnvelope(item);
|
|
2069
|
-
this.metricsManager!.trackEmailReceived(envelope.from);
|
|
2070
2128
|
updateQueueSize();
|
|
2071
2129
|
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
|
2072
2130
|
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
3
|
+
|
|
4
|
+
const DB_OPERATION_TIMEOUT_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
7
|
+
|
|
8
|
+
export interface IEmailTrafficBucketCounts {
|
|
9
|
+
sent: number;
|
|
10
|
+
received: number;
|
|
11
|
+
failed: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IEmailTrafficBucketSnapshot extends IEmailTrafficBucketCounts {
|
|
15
|
+
bucketStart: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@plugins.smartdata.Collection(() => getDb())
|
|
19
|
+
export class EmailTrafficBucketDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
20
|
+
EmailTrafficBucketDoc,
|
|
21
|
+
EmailTrafficBucketDoc
|
|
22
|
+
> implements IEmailTrafficBucketSnapshot {
|
|
23
|
+
@plugins.smartdata.unI()
|
|
24
|
+
@plugins.smartdata.svDb()
|
|
25
|
+
public id!: string;
|
|
26
|
+
|
|
27
|
+
@plugins.smartdata.svDb()
|
|
28
|
+
public bucketStart!: number;
|
|
29
|
+
|
|
30
|
+
@plugins.smartdata.svDb()
|
|
31
|
+
public sent: number = 0;
|
|
32
|
+
|
|
33
|
+
@plugins.smartdata.svDb()
|
|
34
|
+
public received: number = 0;
|
|
35
|
+
|
|
36
|
+
@plugins.smartdata.svDb()
|
|
37
|
+
public failed: number = 0;
|
|
38
|
+
|
|
39
|
+
@plugins.smartdata.svDb()
|
|
40
|
+
public createdAt!: number;
|
|
41
|
+
|
|
42
|
+
@plugins.smartdata.svDb()
|
|
43
|
+
public updatedAt!: number;
|
|
44
|
+
|
|
45
|
+
constructor() {
|
|
46
|
+
super();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static async getNativeCollection() {
|
|
50
|
+
const smartdataCollection = (EmailTrafficBucketDoc as typeof EmailTrafficBucketDoc & {
|
|
51
|
+
collection: plugins.smartdata.SmartdataCollection<EmailTrafficBucketDoc>;
|
|
52
|
+
}).collection;
|
|
53
|
+
await smartdataCollection.init();
|
|
54
|
+
const probe = new EmailTrafficBucketDoc();
|
|
55
|
+
await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
|
|
56
|
+
await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
|
|
57
|
+
return smartdataCollection.mongoDbCollection;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private static validateSnapshot(snapshotArg: IEmailTrafficBucketSnapshot): void {
|
|
61
|
+
const counts = [snapshotArg.sent, snapshotArg.received, snapshotArg.failed];
|
|
62
|
+
if (
|
|
63
|
+
!Number.isSafeInteger(snapshotArg.bucketStart)
|
|
64
|
+
|| snapshotArg.bucketStart < 0
|
|
65
|
+
|| snapshotArg.bucketStart % 60_000 !== 0
|
|
66
|
+
|| counts.some((count) => !Number.isSafeInteger(count) || count < 0)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error('Invalid email traffic bucket snapshot');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Persist retry-safe absolute monotonic counters. */
|
|
73
|
+
public static async persistAbsolute(snapshotsArg: IEmailTrafficBucketSnapshot[]): Promise<void> {
|
|
74
|
+
if (snapshotsArg.length === 0) return;
|
|
75
|
+
if (snapshotsArg.length > 500) {
|
|
76
|
+
throw new Error('EmailTrafficBucketDoc.persistAbsolute accepts at most 500 buckets');
|
|
77
|
+
}
|
|
78
|
+
for (const snapshot of snapshotsArg) {
|
|
79
|
+
EmailTrafficBucketDoc.validateSnapshot(snapshot);
|
|
80
|
+
}
|
|
81
|
+
const collection = await EmailTrafficBucketDoc.getNativeCollection();
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const updatedAt = new Date(now).toISOString();
|
|
84
|
+
await collection.bulkWrite(
|
|
85
|
+
snapshotsArg.map((snapshot) => ({
|
|
86
|
+
updateOne: {
|
|
87
|
+
filter: { bucketStart: snapshot.bucketStart },
|
|
88
|
+
update: {
|
|
89
|
+
$max: {
|
|
90
|
+
sent: snapshot.sent,
|
|
91
|
+
received: snapshot.received,
|
|
92
|
+
failed: snapshot.failed,
|
|
93
|
+
},
|
|
94
|
+
$set: {
|
|
95
|
+
updatedAt: now,
|
|
96
|
+
_updatedAt: updatedAt,
|
|
97
|
+
},
|
|
98
|
+
$setOnInsert: {
|
|
99
|
+
id: `email-traffic-${snapshot.bucketStart}`,
|
|
100
|
+
bucketStart: snapshot.bucketStart,
|
|
101
|
+
createdAt: now,
|
|
102
|
+
_createdAt: updatedAt,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
upsert: true,
|
|
106
|
+
},
|
|
107
|
+
})),
|
|
108
|
+
{ ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public static async loadSince(cutoffArg: number): Promise<IEmailTrafficBucketSnapshot[]> {
|
|
113
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
114
|
+
throw new Error('EmailTrafficBucketDoc.loadSince requires a valid cutoff');
|
|
115
|
+
}
|
|
116
|
+
const collection = await EmailTrafficBucketDoc.getNativeCollection();
|
|
117
|
+
const cursor = collection
|
|
118
|
+
.find(
|
|
119
|
+
{ bucketStart: { $gte: cutoffArg } },
|
|
120
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
121
|
+
)
|
|
122
|
+
.sort({ bucketStart: 1 });
|
|
123
|
+
try {
|
|
124
|
+
const rows = await cursor.toArray();
|
|
125
|
+
return rows.map((row) => ({
|
|
126
|
+
bucketStart: Number(row.bucketStart),
|
|
127
|
+
sent: Number(row.sent || 0),
|
|
128
|
+
received: Number(row.received || 0),
|
|
129
|
+
failed: Number(row.failed || 0),
|
|
130
|
+
}));
|
|
131
|
+
} finally {
|
|
132
|
+
await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public static async pruneBefore(cutoffArg: number): Promise<number> {
|
|
137
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
138
|
+
throw new Error('EmailTrafficBucketDoc.pruneBefore requires a valid cutoff');
|
|
139
|
+
}
|
|
140
|
+
const collection = await EmailTrafficBucketDoc.getNativeCollection();
|
|
141
|
+
let deletedCount = 0;
|
|
142
|
+
while (true) {
|
|
143
|
+
const rows = await collection
|
|
144
|
+
.find(
|
|
145
|
+
{ bucketStart: { $lt: cutoffArg } },
|
|
146
|
+
{ projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
147
|
+
)
|
|
148
|
+
.sort({ bucketStart: 1, _id: 1 })
|
|
149
|
+
.limit(500)
|
|
150
|
+
.toArray();
|
|
151
|
+
if (rows.length === 0) break;
|
|
152
|
+
const result = await collection.deleteMany(
|
|
153
|
+
{ _id: { $in: rows.map((row) => row._id) } },
|
|
154
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
155
|
+
);
|
|
156
|
+
deletedCount += result.deletedCount;
|
|
157
|
+
if (rows.length < 500) break;
|
|
158
|
+
}
|
|
159
|
+
return deletedCount;
|
|
160
|
+
}
|
|
161
|
+
}
|