@serve.zone/dcrouter 18.1.0 → 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.
Files changed (47) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +8 -8
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +4 -1
  5. package/dist_ts/classes.dcrouter.js +30 -6
  6. package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +25 -0
  7. package/dist_ts/db/documents/classes.authentication-event.doc.js +273 -0
  8. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.d.ts +25 -0
  9. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +200 -0
  10. package/dist_ts/db/documents/index.d.ts +2 -0
  11. package/dist_ts/db/documents/index.js +3 -1
  12. package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -0
  13. package/dist_ts/email/classes.accepted-email-spool.js +9 -1
  14. package/dist_ts/monitoring/classes.metricsmanager.d.ts +19 -0
  15. package/dist_ts/monitoring/classes.metricsmanager.js +240 -44
  16. package/dist_ts/opsserver/handlers/admin.handler.d.ts +1 -0
  17. package/dist_ts/opsserver/handlers/admin.handler.js +61 -14
  18. package/dist_ts/opsserver/handlers/security.handler.js +21 -3
  19. package/dist_ts/opsserver/handlers/stats.handler.js +27 -10
  20. package/dist_ts/security/classes.authentication-event-manager.d.ts +35 -0
  21. package/dist_ts/security/classes.authentication-event-manager.js +185 -0
  22. package/dist_ts/security/index.d.ts +1 -0
  23. package/dist_ts/security/index.js +2 -1
  24. package/dist_ts_interfaces/data/stats.d.ts +16 -0
  25. package/dist_ts_migrations/index.js +74 -15
  26. package/dist_ts_web/00_commitinfo_data.js +1 -1
  27. package/dist_ts_web/elements/overview/ops-view-overview.js +3 -2
  28. package/dist_ts_web/elements/security/ops-view-security-authentication.js +20 -13
  29. package/dist_ts_web/elements/security/ops-view-security-overview.js +8 -11
  30. package/package.json +1 -1
  31. package/readme.md +8 -0
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts/classes.dcrouter.ts +31 -5
  34. package/ts/db/documents/classes.authentication-event.doc.ts +248 -0
  35. package/ts/db/documents/classes.email-traffic-bucket.doc.ts +161 -0
  36. package/ts/db/documents/index.ts +2 -0
  37. package/ts/email/classes.accepted-email-spool.ts +8 -0
  38. package/ts/monitoring/classes.metricsmanager.ts +287 -50
  39. package/ts/opsserver/handlers/admin.handler.ts +74 -15
  40. package/ts/opsserver/handlers/security.handler.ts +23 -2
  41. package/ts/opsserver/handlers/stats.handler.ts +29 -9
  42. package/ts/security/classes.authentication-event-manager.ts +221 -0
  43. package/ts/security/index.ts +1 -0
  44. package/ts_web/00_commitinfo_data.ts +1 -1
  45. package/ts_web/elements/overview/ops-view-overview.ts +2 -1
  46. package/ts_web/elements/security/ops-view-security-authentication.ts +20 -13
  47. package/ts_web/elements/security/ops-view-security-overview.ts +7 -12
@@ -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
+ }
@@ -4,6 +4,8 @@ export * from './classes.cached.ip.reputation.js';
4
4
  export * from './classes.ip-intelligence.doc.js';
5
5
  export * from './classes.security-block-rule.doc.js';
6
6
  export * from './classes.security-policy-audit.doc.js';
7
+ export * from './classes.authentication-event.doc.js';
8
+ export * from './classes.email-traffic-bucket.doc.js';
7
9
 
8
10
  // Config document classes
9
11
  export * from './classes.route.doc.js';
@@ -256,6 +256,7 @@ export class AcceptedEmailSpool {
256
256
  await this.notifyEmailQueuePersisted(cachedEmail, 'email-accepted-without-queue');
257
257
  }
258
258
 
259
+ this.trackAcceptedInboundEmail(cachedEmail);
259
260
  return {
260
261
  accepted: true,
261
262
  smtpCode: 250,
@@ -319,6 +320,7 @@ export class AcceptedEmailSpool {
319
320
  await this.notifyEmailQueuePersisted(cachedEmail, 'raw-email-accepted-without-queue');
320
321
  }
321
322
 
323
+ this.trackAcceptedInboundEmail(cachedEmail);
322
324
  return {
323
325
  accepted: true,
324
326
  spoolItemId: cachedEmail.id,
@@ -480,6 +482,12 @@ export class AcceptedEmailSpool {
480
482
  logger.log('warn', `Durable envelope ${cachedEmail.id}: ${failed.length}/${dispatchResult.results.length} non-store recipients failed dispatch: ${failed.map((result) => `${result.recipient}=${result.message || result.status}`).join('; ')}`);
481
483
  }
482
484
  }
485
+ this.trackAcceptedInboundEmail(cachedEmail);
486
+ }
487
+
488
+ private trackAcceptedInboundEmail(cachedEmailArg: CachedEmail): void {
489
+ if (cachedEmailArg.direction !== 'inbound') return;
490
+ this.dcRouterRef.metricsManager?.trackEmailReceived(cachedEmailArg.from);
483
491
  }
484
492
 
485
493
  /**