@serve.zone/dcrouter 18.9.2 → 18.9.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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +5 -4
- package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +0 -1
- package/dist_ts/db/documents/classes.authentication-event.doc.js +176 -259
- package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +110 -188
- package/dist_ts/dns/manager.dns-authority.d.ts +5 -0
- package/dist_ts/dns/manager.dns-authority.js +97 -53
- package/dist_ts/dns/manager.dns.js +23 -5
- package/dist_ts/email/classes.email-domain.manager.js +14 -3
- package/dist_ts/email/classes.email-settings.manager.js +26 -6
- package/dist_ts/email/classes.mail-dns-sync.js +22 -2
- package/dist_ts/monitoring/classes.metricscache.d.ts +28 -19
- package/dist_ts/monitoring/classes.metricscache.js +133 -62
- package/dist_ts/monitoring/classes.metricsmanager.js +7 -5
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts/remoteingress/classes.remoteingress-manager.js +46 -9
- package/dist_ts/security/classes.authentication-event-manager.d.ts +5 -1
- package/dist_ts/security/classes.authentication-event-manager.js +32 -22
- package/dist_ts/vpn/classes.vpn-manager.js +34 -2
- package/dist_ts_migrations/index.d.ts +4 -1
- package/dist_ts_migrations/index.js +18 -3
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +9 -8
- package/readme.hints.md +72 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +4 -3
- package/ts/db/documents/classes.authentication-event.doc.ts +64 -87
- package/ts/db/documents/classes.email-traffic-bucket.doc.ts +10 -9
- package/ts/dns/manager.dns-authority.ts +92 -54
- package/ts/dns/manager.dns.ts +20 -4
- package/ts/email/classes.email-domain.manager.ts +12 -2
- package/ts/email/classes.email-settings.manager.ts +23 -5
- package/ts/email/classes.mail-dns-sync.ts +23 -1
- package/ts/monitoring/classes.metricscache.ts +133 -79
- package/ts/monitoring/classes.metricsmanager.ts +6 -4
- package/ts/plugins.ts +2 -0
- package/ts/remoteingress/classes.remoteingress-manager.ts +42 -8
- package/ts/security/classes.authentication-event-manager.ts +31 -20
- package/ts/vpn/classes.vpn-manager.ts +32 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@serve.zone/dcrouter',
|
|
6
|
-
version: '18.9.
|
|
6
|
+
version: '18.9.4',
|
|
7
7
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
|
8
8
|
};
|
|
9
9
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serve.zone/dcrouter",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "18.9.
|
|
4
|
+
"version": "18.9.4",
|
|
5
5
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
"@git.zone/tsbuild": "^4.4.2",
|
|
19
19
|
"@git.zone/tsbundle": "^2.13.0",
|
|
20
20
|
"@git.zone/tsdeno": "^1.7.0",
|
|
21
|
-
"@git.zone/tsdocker": "^3.5.
|
|
21
|
+
"@git.zone/tsdocker": "^3.5.4",
|
|
22
22
|
"@git.zone/tsrun": "^2.0.6",
|
|
23
23
|
"@git.zone/tstest": "^4.0.0",
|
|
24
24
|
"@git.zone/tswatch": "^3.3.5",
|
|
25
25
|
"@push.rocks/smartdb-v2": "npm:@push.rocks/smartdb@2.14.6",
|
|
26
|
-
"@
|
|
26
|
+
"@push.rocks/smartdb-v5.2": "npm:@push.rocks/smartdb@5.2.2",
|
|
27
|
+
"@types/node": "26.4.1",
|
|
27
28
|
"@types/web-push": "3.6.4"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
@@ -34,15 +35,15 @@
|
|
|
34
35
|
"@apiclient.xyz/cloudflare": "^8.0.0",
|
|
35
36
|
"@design.estate/dees-catalog": "^3.104.0",
|
|
36
37
|
"@design.estate/dees-element": "^2.4.0",
|
|
37
|
-
"@idp.global/sdk": "^
|
|
38
|
+
"@idp.global/sdk": "^16.0.0",
|
|
38
39
|
"@push.rocks/lik": "^6.4.1",
|
|
39
40
|
"@push.rocks/projectinfo": "^5.1.0",
|
|
40
41
|
"@push.rocks/qenv": "^6.1.4",
|
|
41
42
|
"@push.rocks/smartacme": "^9.5.0",
|
|
42
43
|
"@push.rocks/smartbucket": "^4.13.0",
|
|
43
44
|
"@push.rocks/smartchallenge": "^1.1.0",
|
|
44
|
-
"@push.rocks/smartdata": "^
|
|
45
|
-
"@push.rocks/smartdb": "5.
|
|
45
|
+
"@push.rocks/smartdata": "^11.9.0",
|
|
46
|
+
"@push.rocks/smartdb": "5.5.2",
|
|
46
47
|
"@push.rocks/smartdns": "^8.2.1",
|
|
47
48
|
"@push.rocks/smartfs": "^1.7.0",
|
|
48
49
|
"@push.rocks/smartguard": "^3.1.2",
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
"@push.rocks/smartnetwork": "^4.10.1",
|
|
56
57
|
"@push.rocks/smartpath": "^6.0.0",
|
|
57
58
|
"@push.rocks/smartpromise": "^4.2.5",
|
|
58
|
-
"@push.rocks/smartproxy": "^27.
|
|
59
|
+
"@push.rocks/smartproxy": "^27.21.0",
|
|
59
60
|
"@push.rocks/smartradius": "^1.3.0",
|
|
60
61
|
"@push.rocks/smartrequest": "^5.0.4",
|
|
61
62
|
"@push.rocks/smartrx": "^3.0.12",
|
|
@@ -66,7 +67,7 @@
|
|
|
66
67
|
"@push.rocks/taskbuffer": "^8.0.2",
|
|
67
68
|
"@serve.zone/catalog": "^2.21.1",
|
|
68
69
|
"@serve.zone/interfaces": "^27.3.1",
|
|
69
|
-
"@serve.zone/remoteingress": "^5.2.
|
|
70
|
+
"@serve.zone/remoteingress": "^5.2.5",
|
|
70
71
|
"@tsclass/tsclass": "^9.5.1",
|
|
71
72
|
"@types/qrcode": "^1.5.6",
|
|
72
73
|
"ipaddr.js": "^2.5.0",
|
package/readme.hints.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Implementation Hints and Learnings
|
|
2
2
|
|
|
3
|
+
## Bounded metrics and owned delegation probes (2026-09-05)
|
|
4
|
+
|
|
5
|
+
`MetricsCache` has separate aggregate and snapshot LRU stores: 256 aggregate
|
|
6
|
+
keys, 64 snapshot keys, and an 8 MiB estimated payload budget per store. The
|
|
7
|
+
estimate includes keys and nested payloads; it is not a process heap limit.
|
|
8
|
+
Idle entries expire without another request. Snapshot APIs retain their existing
|
|
9
|
+
1,000-record page limit; an oversized page is returned without being cached.
|
|
10
|
+
The facade admits at most 32 computations, including invalidated work that has
|
|
11
|
+
not settled. Saturation uses the existing TypedRequest error envelope with
|
|
12
|
+
`code: METRICS_CAPACITY` and `retryAfterMs: 500`. Callers retain the last displayed
|
|
13
|
+
result and use their normal polling interval. Stop clears caches and awaits
|
|
14
|
+
admitted work; stopped-generation completions cannot repopulate a cache.
|
|
15
|
+
|
|
16
|
+
Authentication statistics share a separate one-second cache with eight keys
|
|
17
|
+
and eight active computations. Recording an event only changes memory and
|
|
18
|
+
invalidates derived results; it does not put database I/O on authentication's
|
|
19
|
+
recording path. Event arrival, actual flushes, pruning, database availability
|
|
20
|
+
changes, and reset invalidate the statistics cache. MetricsManager still owns
|
|
21
|
+
the final durable flush during shutdown.
|
|
22
|
+
|
|
23
|
+
AuthenticationEventDoc uses SmartData 11.9's bounded insert-if-absent batch API
|
|
24
|
+
so a flush retry preserves the complete first event body and timestamps. Window
|
|
25
|
+
statistics use one grouped count plus a sorted bounded page, both with five-second
|
|
26
|
+
server/client deadlines; both queries drain before the computation settles.
|
|
27
|
+
Buffered-ID checks and prune selection use bounded public offset pages. Pruning
|
|
28
|
+
deletes at most 500 selected identities per request behind the same cutoff.
|
|
29
|
+
The authentication and email-traffic model declarations use the exact index
|
|
30
|
+
names already owned by the monitoring migration; no stored-data or index rename
|
|
31
|
+
is required. SDK 16 accepts the same SmartData major for persisted admin accounts.
|
|
32
|
+
|
|
33
|
+
SmartData 8 and later omit undefined values on save. Explicit settings clears
|
|
34
|
+
therefore use model-owned atomic updates with `$unset`: staged DKIM promotion,
|
|
35
|
+
domain edge pins, final email-port settings, hub performance, edge public
|
|
36
|
+
identities, DNS provider errors, and VPN static-IP/VLAN settings. Reload tests
|
|
37
|
+
cover these transitions. Legacy stored nulls remain readable as absent optional
|
|
38
|
+
values. Omitted patch fields do not request removal.
|
|
39
|
+
|
|
40
|
+
Delegation probes coalesce by canonical zone and admit at most 32 owned DNS
|
|
41
|
+
clients. Every outcome awaits client termination. Failed termination retains
|
|
42
|
+
ownership so shutdown can retry and report failure; stop never silently forgets
|
|
43
|
+
a live helper. Probe clients use bounded DoH requests without fallback.
|
|
44
|
+
|
|
45
|
+
## SmartDB 5.2.2 to 5.5.2 rollback qualification (2026-09-05)
|
|
46
|
+
|
|
47
|
+
`test/test.smartdb-rollback.node.ts` runs both published engine versions in
|
|
48
|
+
isolated processes through public SmartData and SmartDB maintenance APIs. It
|
|
49
|
+
checks a clean 5.2.2 checkpoint, a 5.5.2 write, clean reopening with 5.2.2, and
|
|
50
|
+
restoring the checkpoint followed by importing the post-upgrade canonical
|
|
51
|
+
export. Document/index digests must match, including the new write. This does
|
|
52
|
+
not qualify reopening an older engine after an unclean shutdown.
|
|
53
|
+
|
|
54
|
+
Application startup targets the latest actual schema migration, currently
|
|
55
|
+
18.8.0, independently of the package version. Existing bridge-only ledgers from
|
|
56
|
+
18.8.0 through the already released 18.9.3 remain at their recorded version;
|
|
57
|
+
unknown newer schemas fail closed. The rollback fixture seeds a production-like
|
|
58
|
+
18.9.2 ledger and verifies that candidate startup preserves it and the old
|
|
59
|
+
18.9.2 migration runner accepts the post-upgrade import. Advance the explicit
|
|
60
|
+
schema target only when adding a real migration.
|
|
61
|
+
|
|
62
|
+
Before a live upgrade, stop the owning process and verify its children exit,
|
|
63
|
+
then preserve the complete configured data tree and the active pnpm installation.
|
|
64
|
+
Never restore that checkpoint over mail/events accepted after the upgrade.
|
|
65
|
+
Preserve post-upgrade data with the qualified export/import procedure and
|
|
66
|
+
verify the canonical digest before reopening service. Restore storage-root
|
|
67
|
+
contents while preserving its bound directory inode. Check the live export
|
|
68
|
+
against the maintenance API's size limits before relying on that rollback path.
|
|
69
|
+
|
|
70
|
+
SmartDB 5.5.2 applies a leading aggregate match before its materialization
|
|
71
|
+
budget. A selective window can therefore remain below the query budget when
|
|
72
|
+
the complete collection exceeds 10,000 documents. This does not turn timestamp
|
|
73
|
+
range queries into indexed lookups or prove that production RSS will fall.
|
|
74
|
+
|
|
3
75
|
## Derived state must name its invalidation trigger (2026-07-25)
|
|
4
76
|
|
|
5
77
|
Three separate production failures on one day had the same shape: **state was
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -21,8 +21,7 @@ import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
|
|
|
21
21
|
// Import unified database
|
|
22
22
|
import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc, CachedEmail } from './db/index.js';
|
|
23
23
|
// Import migration runner and app version
|
|
24
|
-
import {
|
|
25
|
-
import { commitinfo } from './00_commitinfo_data.js';
|
|
24
|
+
import { createApplicationMigrationRunner } from '../ts_migrations/index.js';
|
|
26
25
|
|
|
27
26
|
import { OpsServer } from './opsserver/index.js';
|
|
28
27
|
import { MetricsManager, OpsEventManager } from './monitoring/index.js';
|
|
@@ -1121,6 +1120,7 @@ export class DcRouter {
|
|
|
1121
1120
|
}
|
|
1122
1121
|
|
|
1123
1122
|
public async start() {
|
|
1123
|
+
this.authenticationEventManager.start();
|
|
1124
1124
|
await this.checkSystemLimits();
|
|
1125
1125
|
logger.log('info', 'Starting DcRouter Services');
|
|
1126
1126
|
await this.serviceManager.start();
|
|
@@ -1273,7 +1273,7 @@ export class DcRouter {
|
|
|
1273
1273
|
|
|
1274
1274
|
// Run any pending data migrations before anything else reads from the DB.
|
|
1275
1275
|
// This must complete before ConfigManagers loads profiles.
|
|
1276
|
-
const migration = await
|
|
1276
|
+
const migration = await createApplicationMigrationRunner(this.dcRouterDb.getDb(), {
|
|
1277
1277
|
remoteIngressHubSettings: this.getRemoteIngressHubSettingsMigrationSeed(),
|
|
1278
1278
|
emailServerSettings: this.getEmailSettingsMigrationSeed(),
|
|
1279
1279
|
legacySmartMtaStoragePath: plugins.path.join(
|
|
@@ -1929,6 +1929,7 @@ export class DcRouter {
|
|
|
1929
1929
|
// Stop accepting authenticated requests before MetricsManager performs its
|
|
1930
1930
|
// final durable authentication-event and email-traffic drain.
|
|
1931
1931
|
await this.stopOpsServer();
|
|
1932
|
+
await this.authenticationEventManager.stop();
|
|
1932
1933
|
|
|
1933
1934
|
// Mail DNS reconciliation may legitimately outlive ServiceManager's
|
|
1934
1935
|
// non-cancelling per-service timeout. Drain it before dependency teardown.
|
|
@@ -11,34 +11,24 @@ const DB_OPERATION_TIMEOUT_MS = 5_000;
|
|
|
11
11
|
|
|
12
12
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
13
13
|
|
|
14
|
-
@plugins.smartdata.Collection(() => getDb())
|
|
15
14
|
export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
16
15
|
AuthenticationEventDoc,
|
|
17
16
|
AuthenticationEventDoc
|
|
18
17
|
> implements IAuthenticationEvent {
|
|
19
|
-
@plugins.smartdata.unI()
|
|
20
|
-
@plugins.smartdata.svDb()
|
|
21
18
|
public id!: string;
|
|
22
19
|
|
|
23
|
-
@plugins.smartdata.svDb()
|
|
24
20
|
public timestamp!: number;
|
|
25
21
|
|
|
26
|
-
@plugins.smartdata.svDb()
|
|
27
22
|
public username!: string;
|
|
28
23
|
|
|
29
|
-
@plugins.smartdata.svDb()
|
|
30
24
|
public userId?: string;
|
|
31
25
|
|
|
32
|
-
@plugins.smartdata.svDb()
|
|
33
26
|
public success!: boolean;
|
|
34
27
|
|
|
35
|
-
@plugins.smartdata.svDb()
|
|
36
28
|
public requestedAuthSource!: TAuthenticationSource;
|
|
37
29
|
|
|
38
|
-
@plugins.smartdata.svDb()
|
|
39
30
|
public resolvedAuthSource?: TResolvedAuthenticationSource;
|
|
40
31
|
|
|
41
|
-
@plugins.smartdata.svDb()
|
|
42
32
|
public failureReason?: TAuthenticationFailureReason;
|
|
43
33
|
|
|
44
34
|
constructor() {
|
|
@@ -58,17 +48,6 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
|
58
48
|
};
|
|
59
49
|
}
|
|
60
50
|
|
|
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
51
|
private static toPersistedFields(eventArg: IAuthenticationEvent): IAuthenticationEvent {
|
|
73
52
|
return {
|
|
74
53
|
id: eventArg.id,
|
|
@@ -137,27 +116,19 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
|
137
116
|
for (const event of eventsArg) {
|
|
138
117
|
AuthenticationEventDoc.validateEvent(event);
|
|
139
118
|
}
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
},
|
|
154
|
-
},
|
|
155
|
-
upsert: true,
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
}),
|
|
159
|
-
{ ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
160
|
-
);
|
|
119
|
+
const documents = new Map<string, AuthenticationEventDoc>();
|
|
120
|
+
for (const event of eventsArg) {
|
|
121
|
+
if (documents.has(event.id)) continue;
|
|
122
|
+
const timestamp = new Date(event.timestamp).toISOString();
|
|
123
|
+
documents.set(event.id, Object.assign(new AuthenticationEventDoc(), {
|
|
124
|
+
...AuthenticationEventDoc.toPersistedFields(event),
|
|
125
|
+
_createdAt: timestamp,
|
|
126
|
+
_updatedAt: timestamp,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
await AuthenticationEventDoc.insertManyIfAbsent([...documents.values()], {
|
|
130
|
+
identityField: 'id', timeoutMS: DB_OPERATION_TIMEOUT_MS,
|
|
131
|
+
});
|
|
161
132
|
}
|
|
162
133
|
|
|
163
134
|
public static async getWindowSummary(
|
|
@@ -174,34 +145,31 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
|
174
145
|
if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) {
|
|
175
146
|
throw new Error('AuthenticationEventDoc.getWindowSummary requires a limit from 1 to 500');
|
|
176
147
|
}
|
|
177
|
-
|
|
148
|
+
await AuthenticationEventDoc.ensureInitialized();
|
|
178
149
|
const selector = { timestamp: { $gte: cutoffArg } };
|
|
179
|
-
const
|
|
180
|
-
.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.toApiObject()),
|
|
201
|
-
};
|
|
202
|
-
} finally {
|
|
203
|
-
await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
|
|
150
|
+
const [totalsResult, rowsResult] = await Promise.allSettled([
|
|
151
|
+
AuthenticationEventDoc.getGroupedTotals({
|
|
152
|
+
filter: selector, groupBy: ['success'], limit: 3,
|
|
153
|
+
maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
|
|
154
|
+
}),
|
|
155
|
+
AuthenticationEventDoc.getOffsetPage(selector, {
|
|
156
|
+
skip: 0, limit: limitArg, sort: { timestamp: -1, id: -1 },
|
|
157
|
+
maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
// Both public queries own a cursor; drain both before releasing this computation.
|
|
161
|
+
if (totalsResult.status === 'rejected') throw totalsResult.reason;
|
|
162
|
+
if (rowsResult.status === 'rejected') throw rowsResult.reason;
|
|
163
|
+
const totals = totalsResult.value;
|
|
164
|
+
const rows = rowsResult.value;
|
|
165
|
+
let successes = 0;
|
|
166
|
+
let failures = 0;
|
|
167
|
+
for (const total of totals) {
|
|
168
|
+
if (total.group.success === true) successes = total.count;
|
|
169
|
+
else if (total.group.success === false) failures = total.count;
|
|
170
|
+
else throw new Error('Authentication events contain an invalid success value');
|
|
204
171
|
}
|
|
172
|
+
return { successes, failures, events: rows.map((row) => row.toApiObject()) };
|
|
205
173
|
}
|
|
206
174
|
|
|
207
175
|
public static async findExistingIds(idsArg: string[]): Promise<Set<string>> {
|
|
@@ -210,34 +178,28 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
|
210
178
|
if (ids.length > 500) {
|
|
211
179
|
throw new Error('AuthenticationEventDoc.findExistingIds accepts at most 500 ids');
|
|
212
180
|
}
|
|
213
|
-
|
|
214
|
-
const rows = await
|
|
215
|
-
.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.toArray();
|
|
220
|
-
return new Set(rows.map((row) => String(row.id)));
|
|
181
|
+
await AuthenticationEventDoc.ensureInitialized();
|
|
182
|
+
const rows = await AuthenticationEventDoc.getOffsetPage({ id: { $in: ids } }, {
|
|
183
|
+
skip: 0, limit: ids.length, sort: { id: 1 }, projection: { id: 1, _id: 0 },
|
|
184
|
+
maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
|
|
185
|
+
});
|
|
186
|
+
return new Set(rows.map((row) => row.id));
|
|
221
187
|
}
|
|
222
188
|
|
|
223
189
|
public static async pruneBefore(cutoffArg: number): Promise<number> {
|
|
224
190
|
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
225
191
|
throw new Error('AuthenticationEventDoc.pruneBefore requires a valid cutoff');
|
|
226
192
|
}
|
|
227
|
-
|
|
193
|
+
await AuthenticationEventDoc.ensureInitialized();
|
|
228
194
|
let deletedCount = 0;
|
|
229
195
|
while (true) {
|
|
230
|
-
const rows = await
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
)
|
|
235
|
-
.sort({ timestamp: 1, _id: 1 })
|
|
236
|
-
.limit(500)
|
|
237
|
-
.toArray();
|
|
196
|
+
const rows = await AuthenticationEventDoc.getOffsetPage({ timestamp: { $lt: cutoffArg } }, {
|
|
197
|
+
skip: 0, limit: 500, sort: { timestamp: 1, id: 1 }, projection: { id: 1, _id: 0 },
|
|
198
|
+
maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
|
|
199
|
+
});
|
|
238
200
|
if (rows.length === 0) break;
|
|
239
|
-
const result = await
|
|
240
|
-
{
|
|
201
|
+
const result = await AuthenticationEventDoc.atomicDeleteMany(
|
|
202
|
+
{ timestamp: { $lt: cutoffArg }, $or: rows.map((row) => ({ id: row.id })) },
|
|
241
203
|
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
242
204
|
);
|
|
243
205
|
deletedCount += result.deletedCount;
|
|
@@ -246,3 +208,18 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
|
246
208
|
return deletedCount;
|
|
247
209
|
}
|
|
248
210
|
}
|
|
211
|
+
|
|
212
|
+
// Match the names installed by create-monitoring-event-indexes on existing hubs.
|
|
213
|
+
plugins.smartdata.defineCollectionModel(AuthenticationEventDoc, getDb, {
|
|
214
|
+
collectionName: 'AuthenticationEventDoc',
|
|
215
|
+
persistedFields: [
|
|
216
|
+
'id', 'timestamp', 'username', 'userId', 'success',
|
|
217
|
+
'requestedAuthSource', 'resolvedAuthSource', 'failureReason',
|
|
218
|
+
],
|
|
219
|
+
identityFields: ['id'],
|
|
220
|
+
indexes: [
|
|
221
|
+
{ name: 'authentication_event_id_unique', key: { id: 1 }, options: { unique: true } },
|
|
222
|
+
{ name: 'authentication_event_timestamp', key: { timestamp: -1 } },
|
|
223
|
+
{ name: 'authentication_event_success_timestamp', key: { success: 1, timestamp: -1 } },
|
|
224
|
+
],
|
|
225
|
+
});
|
|
@@ -15,31 +15,22 @@ export interface IEmailTrafficBucketSnapshot extends IEmailTrafficBucketCounts {
|
|
|
15
15
|
bucketStart: number;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
@plugins.smartdata.Collection(() => getDb())
|
|
19
18
|
export class EmailTrafficBucketDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
20
19
|
EmailTrafficBucketDoc,
|
|
21
20
|
EmailTrafficBucketDoc
|
|
22
21
|
> implements IEmailTrafficBucketSnapshot {
|
|
23
|
-
@plugins.smartdata.unI()
|
|
24
|
-
@plugins.smartdata.svDb()
|
|
25
22
|
public id!: string;
|
|
26
23
|
|
|
27
|
-
@plugins.smartdata.svDb()
|
|
28
24
|
public bucketStart!: number;
|
|
29
25
|
|
|
30
|
-
@plugins.smartdata.svDb()
|
|
31
26
|
public sent: number = 0;
|
|
32
27
|
|
|
33
|
-
@plugins.smartdata.svDb()
|
|
34
28
|
public received: number = 0;
|
|
35
29
|
|
|
36
|
-
@plugins.smartdata.svDb()
|
|
37
30
|
public failed: number = 0;
|
|
38
31
|
|
|
39
|
-
@plugins.smartdata.svDb()
|
|
40
32
|
public createdAt!: number;
|
|
41
33
|
|
|
42
|
-
@plugins.smartdata.svDb()
|
|
43
34
|
public updatedAt!: number;
|
|
44
35
|
|
|
45
36
|
constructor() {
|
|
@@ -159,3 +150,13 @@ export class EmailTrafficBucketDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
|
159
150
|
return deletedCount;
|
|
160
151
|
}
|
|
161
152
|
}
|
|
153
|
+
|
|
154
|
+
plugins.smartdata.defineCollectionModel(EmailTrafficBucketDoc, getDb, {
|
|
155
|
+
collectionName: 'EmailTrafficBucketDoc',
|
|
156
|
+
persistedFields: ['id', 'bucketStart', 'sent', 'received', 'failed', 'createdAt', 'updatedAt'],
|
|
157
|
+
identityFields: ['id'],
|
|
158
|
+
indexes: [
|
|
159
|
+
{ name: 'email_traffic_bucket_id_unique', key: { id: 1 }, options: { unique: true } },
|
|
160
|
+
{ name: 'email_traffic_bucket_start_unique', key: { bucketStart: 1 }, options: { unique: true } },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
@@ -99,6 +99,9 @@ export class DnsAuthorityManager {
|
|
|
99
99
|
private updatedBy = '';
|
|
100
100
|
private state: TDnsAuthorityState = 'unavailable';
|
|
101
101
|
private reconciler?: TDnsAuthorityReconciler;
|
|
102
|
+
private stopped = false;
|
|
103
|
+
private activeProbes = new Map<string, Promise<IDnsDelegationProbe>>();
|
|
104
|
+
private ownedProbeClients = new Set<plugins.smartdns.dnsClientMod.Smartdns>();
|
|
102
105
|
|
|
103
106
|
constructor(
|
|
104
107
|
private getExpectedNameservers: () => string[],
|
|
@@ -109,6 +112,8 @@ export class DnsAuthorityManager {
|
|
|
109
112
|
// ==========================================================================
|
|
110
113
|
|
|
111
114
|
public async start(): Promise<void> {
|
|
115
|
+
if (this.ownedProbeClients.size > 0) throw new Error('DNS probe cleanup must complete before restart');
|
|
116
|
+
this.stopped = false;
|
|
112
117
|
let doc: DnsAuthorityDoc | null;
|
|
113
118
|
try {
|
|
114
119
|
doc = await DnsAuthorityDoc.load();
|
|
@@ -154,9 +159,17 @@ export class DnsAuthorityManager {
|
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
public async stop(): Promise<void> {
|
|
162
|
+
this.stopped = true;
|
|
163
|
+
await Promise.allSettled(this.activeProbes.values());
|
|
164
|
+
const cleanup = await Promise.allSettled([...this.ownedProbeClients].map(async (client) => {
|
|
165
|
+
await client.terminate();
|
|
166
|
+
this.ownedProbeClients.delete(client);
|
|
167
|
+
}));
|
|
157
168
|
this.verifiedZones = [];
|
|
158
169
|
this.state = 'unavailable';
|
|
159
170
|
this.reconciler = undefined;
|
|
171
|
+
const failures = cleanup.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
172
|
+
if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'DNS probe cleanup failed');
|
|
160
173
|
}
|
|
161
174
|
|
|
162
175
|
/**
|
|
@@ -223,7 +236,28 @@ export class DnsAuthorityManager {
|
|
|
223
236
|
* that a zone is not ours; collapsing 'undeterminable' into 'not-delegated'
|
|
224
237
|
* would let a transient resolver fault revoke authority.
|
|
225
238
|
*/
|
|
226
|
-
public
|
|
239
|
+
public probeDelegation(zoneArg: string): Promise<IDnsDelegationProbe> {
|
|
240
|
+
if (this.stopped) return Promise.reject(new Error('DNS authority manager is stopped'));
|
|
241
|
+
const zone = normalizeZone(zoneArg);
|
|
242
|
+
const existing = this.activeProbes.get(zone);
|
|
243
|
+
if (existing) return existing;
|
|
244
|
+
if (this.ownedProbeClients.size >= 32) return Promise.reject(new Error('DNS probe capacity reached'));
|
|
245
|
+
const run = this.probeDelegationUnlocked(zoneArg).finally(() => {
|
|
246
|
+
if (this.activeProbes.get(zone) === run) this.activeProbes.delete(zone);
|
|
247
|
+
});
|
|
248
|
+
this.activeProbes.set(zone, run);
|
|
249
|
+
return run;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private createProbeClient(): plugins.smartdns.dnsClientMod.Smartdns {
|
|
253
|
+
return new plugins.smartdns.dnsClientMod.Smartdns({
|
|
254
|
+
strategy: 'doh',
|
|
255
|
+
allowDohFallback: false,
|
|
256
|
+
timeoutMs: 10_000,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async probeDelegationUnlocked(zoneArg: string): Promise<IDnsDelegationProbe> {
|
|
227
261
|
const zone = normalizeZone(zoneArg);
|
|
228
262
|
const expectedNameservers = this.getExpectedNameservers()
|
|
229
263
|
.map(normalizeNameserver)
|
|
@@ -248,69 +282,72 @@ export class DnsAuthorityManager {
|
|
|
248
282
|
};
|
|
249
283
|
}
|
|
250
284
|
|
|
251
|
-
const smartdnsClient =
|
|
252
|
-
|
|
253
|
-
allowDohFallback: false,
|
|
254
|
-
timeoutMs: 10_000,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
let result: Awaited<ReturnType<typeof smartdnsClient.queryRecords>>;
|
|
285
|
+
const smartdnsClient = this.createProbeClient();
|
|
286
|
+
this.ownedProbeClients.add(smartdnsClient);
|
|
258
287
|
try {
|
|
259
|
-
result = await smartdnsClient.queryRecords(zone, 'NS');
|
|
260
|
-
} catch (error: unknown) {
|
|
261
|
-
return {
|
|
262
|
-
zone,
|
|
263
|
-
verdict: 'undeterminable',
|
|
264
|
-
observedNameservers: [],
|
|
265
|
-
expectedNameservers,
|
|
266
|
-
detail: `delegation lookup threw: ${(error as Error).message}`,
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
288
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
zone,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
289
|
+
let result: Awaited<ReturnType<typeof smartdnsClient.queryRecords>>;
|
|
290
|
+
try {
|
|
291
|
+
result = await smartdnsClient.queryRecords(zone, 'NS');
|
|
292
|
+
} catch (error: unknown) {
|
|
293
|
+
return {
|
|
294
|
+
zone,
|
|
295
|
+
verdict: 'undeterminable',
|
|
296
|
+
observedNameservers: [],
|
|
297
|
+
expectedNameservers,
|
|
298
|
+
detail: `delegation lookup threw: ${(error as Error).message}`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
279
301
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
302
|
+
if (result.status === 'error') {
|
|
303
|
+
return {
|
|
304
|
+
zone,
|
|
305
|
+
verdict: 'undeterminable',
|
|
306
|
+
observedNameservers: [],
|
|
307
|
+
expectedNameservers,
|
|
308
|
+
detail: `delegation lookup failed (${result.error.kind}/${result.error.code}): ${result.error.message}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (result.status === 'missing') {
|
|
313
|
+
return {
|
|
314
|
+
zone,
|
|
315
|
+
verdict: 'not-delegated',
|
|
316
|
+
observedNameservers: [],
|
|
317
|
+
expectedNameservers,
|
|
318
|
+
detail: result.missingReason === 'nxdomain'
|
|
319
|
+
? `${zone} does not exist in public DNS`
|
|
320
|
+
: `${zone} has no NS records in public DNS`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
291
323
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
324
|
+
const observedNameservers: string[] = [...new Set<string>(
|
|
325
|
+
result.records
|
|
326
|
+
.map((record): string => normalizeNameserver(String(record.value ?? '')))
|
|
327
|
+
.filter((nameserver): nameserver is string => Boolean(nameserver)),
|
|
328
|
+
)];
|
|
329
|
+
const matches = observedNameservers.filter((observed) => expectedNameservers.includes(observed));
|
|
330
|
+
if (matches.length === 0) {
|
|
331
|
+
return {
|
|
332
|
+
zone,
|
|
333
|
+
verdict: 'not-delegated',
|
|
334
|
+
observedNameservers,
|
|
335
|
+
expectedNameservers,
|
|
336
|
+
detail: `${zone} is delegated to ${observedNameservers.join(', ') || 'nothing'}, which does not include any of our nameservers (${expectedNameservers.join(', ')})`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
299
339
|
return {
|
|
300
340
|
zone,
|
|
301
|
-
verdict: '
|
|
341
|
+
verdict: 'delegated',
|
|
302
342
|
observedNameservers,
|
|
303
343
|
expectedNameservers,
|
|
304
|
-
detail: `${zone} is delegated to ${
|
|
344
|
+
detail: `${zone} is delegated to ${matches.join(', ')}`,
|
|
305
345
|
};
|
|
346
|
+
} finally {
|
|
347
|
+
// Keep ownership if confirmed termination fails; stop() can retry it.
|
|
348
|
+
await smartdnsClient.terminate();
|
|
349
|
+
this.ownedProbeClients.delete(smartdnsClient);
|
|
306
350
|
}
|
|
307
|
-
return {
|
|
308
|
-
zone,
|
|
309
|
-
verdict: 'delegated',
|
|
310
|
-
observedNameservers,
|
|
311
|
-
expectedNameservers,
|
|
312
|
-
detail: `${zone} is delegated to ${matches.join(', ')}`,
|
|
313
|
-
};
|
|
314
351
|
}
|
|
315
352
|
|
|
316
353
|
// ==========================================================================
|
|
@@ -328,6 +365,7 @@ export class DnsAuthorityManager {
|
|
|
328
365
|
}
|
|
329
366
|
|
|
330
367
|
const probe = await this.probeDelegation(zone);
|
|
368
|
+
if (this.stopped) return { success: false, message: 'DNS authority manager is stopped', probe };
|
|
331
369
|
if (probe.verdict !== 'delegated') {
|
|
332
370
|
return {
|
|
333
371
|
success: false,
|