@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.
Files changed (42) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +5 -4
  4. package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +0 -1
  5. package/dist_ts/db/documents/classes.authentication-event.doc.js +176 -259
  6. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +110 -188
  7. package/dist_ts/dns/manager.dns-authority.d.ts +5 -0
  8. package/dist_ts/dns/manager.dns-authority.js +97 -53
  9. package/dist_ts/dns/manager.dns.js +23 -5
  10. package/dist_ts/email/classes.email-domain.manager.js +14 -3
  11. package/dist_ts/email/classes.email-settings.manager.js +26 -6
  12. package/dist_ts/email/classes.mail-dns-sync.js +22 -2
  13. package/dist_ts/monitoring/classes.metricscache.d.ts +28 -19
  14. package/dist_ts/monitoring/classes.metricscache.js +133 -62
  15. package/dist_ts/monitoring/classes.metricsmanager.js +7 -5
  16. package/dist_ts/plugins.d.ts +2 -1
  17. package/dist_ts/plugins.js +3 -2
  18. package/dist_ts/remoteingress/classes.remoteingress-manager.js +46 -9
  19. package/dist_ts/security/classes.authentication-event-manager.d.ts +5 -1
  20. package/dist_ts/security/classes.authentication-event-manager.js +32 -22
  21. package/dist_ts/vpn/classes.vpn-manager.js +34 -2
  22. package/dist_ts_migrations/index.d.ts +4 -1
  23. package/dist_ts_migrations/index.js +18 -3
  24. package/dist_ts_web/00_commitinfo_data.js +1 -1
  25. package/package.json +9 -8
  26. package/readme.hints.md +72 -0
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/classes.dcrouter.ts +4 -3
  29. package/ts/db/documents/classes.authentication-event.doc.ts +64 -87
  30. package/ts/db/documents/classes.email-traffic-bucket.doc.ts +10 -9
  31. package/ts/dns/manager.dns-authority.ts +92 -54
  32. package/ts/dns/manager.dns.ts +20 -4
  33. package/ts/email/classes.email-domain.manager.ts +12 -2
  34. package/ts/email/classes.email-settings.manager.ts +23 -5
  35. package/ts/email/classes.mail-dns-sync.ts +23 -1
  36. package/ts/monitoring/classes.metricscache.ts +133 -79
  37. package/ts/monitoring/classes.metricsmanager.ts +6 -4
  38. package/ts/plugins.ts +2 -0
  39. package/ts/remoteingress/classes.remoteingress-manager.ts +42 -8
  40. package/ts/security/classes.authentication-event-manager.ts +31 -20
  41. package/ts/vpn/classes.vpn-manager.ts +32 -1
  42. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -836,7 +836,15 @@ export class DnsManager {
836
836
  this.providerClients.delete(id);
837
837
  }
838
838
  doc.updatedAt = Date.now();
839
- await doc.save();
839
+ const persisted = await DnsProviderDoc.atomicUpdate({ id }, {
840
+ $set: {
841
+ name: doc.name,
842
+ updatedAt: doc.updatedAt,
843
+ ...(args.credentials !== undefined ? { credentials: doc.credentials, status: doc.status } : {}),
844
+ },
845
+ ...(args.credentials !== undefined ? { $unset: { lastError: true } } : {}),
846
+ });
847
+ if (persisted.matchedCount !== 1) throw new Error('DNS provider disappeared during update');
840
848
  return true;
841
849
  }
842
850
 
@@ -889,7 +897,15 @@ export class DnsManager {
889
897
  doc.status = result.ok ? 'ok' : 'error';
890
898
  doc.lastTestedAt = Date.now();
891
899
  doc.lastError = result.ok ? undefined : result.error;
892
- await doc.save();
900
+ const persisted = await DnsProviderDoc.atomicUpdate({ id, credentials: { $eq: doc.credentials } }, {
901
+ $set: {
902
+ status: doc.status,
903
+ lastTestedAt: doc.lastTestedAt,
904
+ ...(doc.lastError !== undefined ? { lastError: doc.lastError } : {}),
905
+ },
906
+ ...(doc.lastError === undefined ? { $unset: { lastError: true } } : {}),
907
+ });
908
+ if (persisted.matchedCount !== 1) throw new Error('DNS provider disappeared during connection test');
893
909
  if (result.ok) {
894
910
  this.providerClients.set(id, client); // cache the working client
895
911
  }
@@ -1774,8 +1790,8 @@ export class DnsManager {
1774
1790
  name: doc.name,
1775
1791
  type: doc.type,
1776
1792
  status: doc.status,
1777
- lastTestedAt: doc.lastTestedAt,
1778
- lastError: doc.lastError,
1793
+ lastTestedAt: doc.lastTestedAt ?? undefined,
1794
+ lastError: doc.lastError ?? undefined,
1779
1795
  createdAt: doc.createdAt,
1780
1796
  updatedAt: doc.updatedAt,
1781
1797
  createdBy: doc.createdBy,
@@ -268,7 +268,17 @@ export class EmailDomainManager {
268
268
  }
269
269
  doc.reconciliation = this.invalidateReconciliation(doc, 'EMAIL_DOMAIN_CONFIGURATION_CHANGED');
270
270
  doc.updatedAt = new Date().toISOString();
271
- await doc.save();
271
+ const persisted = await EmailDomainDoc.atomicUpdate({ id }, {
272
+ $set: {
273
+ dkim: doc.dkim,
274
+ reconciliation: doc.reconciliation,
275
+ updatedAt: doc.updatedAt,
276
+ ...(changes.rateLimits !== undefined ? { rateLimits: doc.rateLimits! } : {}),
277
+ ...(doc.remoteIngress != null ? { remoteIngress: doc.remoteIngress } : {}),
278
+ },
279
+ ...(changes.remoteIngress === null ? { $unset: { remoteIngress: true } } : {}),
280
+ });
281
+ if (persisted.matchedCount !== 1) throw new Error('Email domain disappeared during update');
272
282
  await this.syncManagedDomainsToRuntime();
273
283
  return { kind: 'sync' as const, domainName: doc.domain };
274
284
  });
@@ -833,7 +843,7 @@ export class EmailDomainManager {
833
843
  subdomain: doc.subdomain,
834
844
  dkim: doc.dkim,
835
845
  rateLimits: doc.rateLimits,
836
- remoteIngress: doc.remoteIngress,
846
+ remoteIngress: doc.remoteIngress ?? undefined,
837
847
  dnsStatus: doc.dnsStatus,
838
848
  reconciliation: doc.reconciliation,
839
849
  activeDkim: doc.activeDkim,
@@ -129,8 +129,8 @@ export class EmailSettingsManager {
129
129
  }
130
130
 
131
131
  const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled;
132
- const nextEmailConfig = this.patchEmailConfig(doc.emailConfig, updates, nextEnabled);
133
- const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig, updates);
132
+ const nextEmailConfig = this.patchEmailConfig(doc.emailConfig ?? undefined, updates, nextEnabled);
133
+ const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig ?? undefined, updates);
134
134
  const nextOutboundMode = this.patchOutboundMode(doc.outboundMode, updates);
135
135
 
136
136
  doc.enabled = nextEnabled;
@@ -139,7 +139,25 @@ export class EmailSettingsManager {
139
139
  doc.outboundMode = nextOutboundMode;
140
140
  doc.updatedAt = Date.now();
141
141
  doc.updatedBy = updatedBy;
142
- await doc.save();
142
+ if (doc.creationStatus === 'new') {
143
+ await doc.save();
144
+ } else {
145
+ const persisted = await EmailServerSettingsDoc.atomicUpdate({ settingsId: doc.settingsId }, {
146
+ $set: {
147
+ enabled: doc.enabled,
148
+ outboundMode: doc.outboundMode,
149
+ updatedAt: doc.updatedAt,
150
+ updatedBy: doc.updatedBy,
151
+ ...(doc.emailConfig !== undefined ? { emailConfig: doc.emailConfig } : {}),
152
+ ...(doc.emailPortConfig !== undefined ? { emailPortConfig: doc.emailPortConfig } : {}),
153
+ },
154
+ $unset: {
155
+ ...((hasOwn(updates, 'portMapping') || hasOwn(updates, 'receivedEmailsPath'))
156
+ && doc.emailPortConfig === undefined ? { emailPortConfig: true } : {}),
157
+ },
158
+ });
159
+ if (persisted.matchedCount !== 1) throw new Error('Email server settings disappeared during update');
160
+ }
143
161
 
144
162
  this.loadFromDoc(doc);
145
163
  this.applyToRuntimeOptions();
@@ -148,8 +166,8 @@ export class EmailSettingsManager {
148
166
 
149
167
  private loadFromDoc(doc: EmailServerSettingsDoc): void {
150
168
  this.enabled = doc.enabled;
151
- this.cachedEmailConfig = clonePlain(doc.emailConfig);
152
- this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig);
169
+ this.cachedEmailConfig = clonePlain(doc.emailConfig ?? undefined);
170
+ this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig ?? undefined);
153
171
  this.outboundMode = this.normalizeOutboundMode(doc.outboundMode);
154
172
  this.updatedAt = doc.updatedAt;
155
173
  this.updatedBy = doc.updatedBy;
@@ -1494,6 +1494,7 @@ export class MailDnsSync {
1494
1494
  mxSlots: plan.revision.mxSlots,
1495
1495
  });
1496
1496
  const now = new Date().toISOString();
1497
+ const promotedMaterial = canActivate ? plan.doc.pendingDkim : undefined;
1497
1498
  if (canActivate && plan.doc.pendingDkim) {
1498
1499
  this.promotePendingDkim(plan.doc, now);
1499
1500
  }
@@ -1534,7 +1535,28 @@ export class MailDnsSync {
1534
1535
  lastCheckedAt: now,
1535
1536
  };
1536
1537
  plan.doc.updatedAt = now;
1537
- await plan.doc.save();
1538
+ if (promotedMaterial) {
1539
+ // Publish the replacement signer and clear its staged material in one write.
1540
+ const result = await EmailDomainDoc.atomicUpdate(
1541
+ { id: plan.doc.id, pendingDkim: { $eq: promotedMaterial } },
1542
+ {
1543
+ $set: {
1544
+ activeDkim: plan.doc.activeDkim!,
1545
+ retiringDkim: plan.doc.retiringDkim!,
1546
+ dkim: plan.doc.dkim,
1547
+ reconciliation: plan.doc.reconciliation,
1548
+ dnsStatus: plan.doc.dnsStatus,
1549
+ updatedAt: now,
1550
+ },
1551
+ $unset: { pendingDkim: true },
1552
+ },
1553
+ );
1554
+ if (result.matchedCount !== 1) {
1555
+ throw new Error('Staged DKIM material changed before promotion');
1556
+ }
1557
+ } else {
1558
+ await plan.doc.save();
1559
+ }
1538
1560
  if (canActivate && topologyChanged) {
1539
1561
  // The transition SPF intentionally authorizes old + new identities. Run
1540
1562
  // one immediate follow-up to contract it to the newly active revision.
@@ -1,103 +1,157 @@
1
+ import * as plugins from '../plugins.js';
2
+
1
3
  export interface ICacheEntry<T> {
2
4
  data: T;
3
5
  timestamp: number;
4
6
  ttl: number;
5
7
  }
6
8
 
7
- export class MetricsCache {
8
- private cache = new Map<string, ICacheEntry<any>>();
9
- private inFlight = new Map<string, Promise<any>>();
10
- private readonly defaultTTL: number;
11
-
12
- constructor(defaultTTL: number = 500) {
13
- this.defaultTTL = defaultTTL;
9
+ export interface IMetricsCacheOptions {
10
+ maxAggregateEntries?: number;
11
+ maxSnapshotEntries?: number;
12
+ maxEstimatedBytes?: number;
13
+ maxInFlight?: number;
14
+ }
15
+
16
+ type TCacheCategory = 'aggregate' | 'snapshot';
17
+
18
+ /** Uses the existing TypedRequest error envelope; polling callers retain their last result. */
19
+ export class MetricsCapacityError extends plugins.typedrequest.TypedResponseError {
20
+ constructor() {
21
+ super('Metrics are busy; retry after 500 ms', { code: 'METRICS_CAPACITY', retryAfterMs: 500 });
14
22
  }
15
-
16
- /**
17
- * Get cached data or compute and cache it
18
- */
19
- public get<T>(key: string, computeFn: () => T | Promise<T>, ttl?: number): T | Promise<T> {
20
- const cached = this.cache.get(key);
21
- const now = Date.now();
22
- const actualTTL = ttl ?? this.defaultTTL;
23
-
24
- if (cached && (now - cached.timestamp) < cached.ttl) {
25
- return cached.data;
26
- }
23
+ }
27
24
 
28
- if (cached) {
29
- this.cache.delete(key);
25
+ /** Conservative retained-payload estimate, including Maps and shared object graphs. */
26
+ function estimateBytes(value: unknown, budget: number, seen = new Set<object>()): number {
27
+ if (budget <= 0) return 1;
28
+ if (typeof value === 'string') return 24 + value.length * 2;
29
+ if (typeof value !== 'object' || value === null) return 16;
30
+ if (seen.has(value)) return 8;
31
+ seen.add(value);
32
+ if (ArrayBuffer.isView(value)) return 64 + value.byteLength;
33
+ if (value instanceof ArrayBuffer) return 64 + value.byteLength;
34
+ let size = 64;
35
+ if (value instanceof Map) {
36
+ for (const [key, entry] of value) {
37
+ size += 32 + estimateBytes(key, budget - size, seen) + estimateBytes(entry, budget - size, seen);
38
+ if (size > budget) break;
30
39
  }
31
-
32
- const pending = this.inFlight.get(key) as Promise<T> | undefined;
33
- if (pending) {
34
- return pending;
40
+ } else if (value instanceof Set) {
41
+ for (const entry of value) {
42
+ size += 16 + estimateBytes(entry, budget - size, seen);
43
+ if (size > budget) break;
35
44
  }
36
-
37
- const result = computeFn();
38
-
39
- // Handle both sync and async compute functions
40
- if (result instanceof Promise) {
41
- let inFlightPromise: Promise<T>;
42
- inFlightPromise = result.then(
43
- (data) => {
44
- if (this.inFlight.get(key) === inFlightPromise) {
45
- this.inFlight.delete(key);
46
- this.cache.set(key, { data, timestamp: Date.now(), ttl: actualTTL });
47
- }
48
- return data;
49
- },
50
- (error: unknown) => {
51
- if (this.inFlight.get(key) === inFlightPromise) {
52
- this.inFlight.delete(key);
53
- this.cache.delete(key);
54
- }
55
- throw error;
56
- },
57
- );
58
- this.inFlight.set(key, inFlightPromise);
59
- return inFlightPromise;
60
- } else {
61
- this.cache.set(key, { data: result, timestamp: now, ttl: actualTTL });
45
+ } else {
46
+ for (const key of Object.keys(value)) {
47
+ size += 24 + key.length * 2 + estimateBytes((value as Record<string, unknown>)[key], budget - size, seen);
48
+ if (size > budget) break;
49
+ }
50
+ }
51
+ return size;
52
+ }
53
+
54
+ export class MetricsCache {
55
+ private readonly caches: Record<TCacheCategory, plugins.lruCache.LRUCache<string, ICacheEntry<unknown>>>;
56
+ private readonly inFlight = new Map<string, Promise<unknown>>();
57
+ private readonly admitted = new Set<Promise<unknown>>();
58
+ private readonly maxInFlight: number;
59
+ private readonly maxEstimatedBytes: number;
60
+ private stopped = false;
61
+ private generation = 0;
62
+ private activeComputations = 0;
63
+
64
+ constructor(private readonly defaultTTL = 500, options: IMetricsCacheOptions = {}) {
65
+ this.maxInFlight = options.maxInFlight ?? 32;
66
+ this.maxEstimatedBytes = options.maxEstimatedBytes ?? 8 * 1024 * 1024;
67
+ this.caches = {
68
+ aggregate: this.createCache(options.maxAggregateEntries ?? 256),
69
+ snapshot: this.createCache(options.maxSnapshotEntries ?? 64),
70
+ };
71
+ }
72
+
73
+ private createCache(max: number): plugins.lruCache.LRUCache<string, ICacheEntry<unknown>> {
74
+ return new plugins.lruCache.LRUCache({
75
+ max,
76
+ maxSize: this.maxEstimatedBytes,
77
+ // lru-cache owns bounded, unref'ed expiry timers, including idle keys.
78
+ ttlAutopurge: true,
79
+ ttlResolution: 0,
80
+ sizeCalculation: (entry, key) => 64 + key.length * 2 + estimateBytes(entry.data, this.maxEstimatedBytes),
81
+ });
82
+ }
83
+
84
+ public get<T>(key: string, computeFn: () => T | Promise<T>, ttl = this.defaultTTL, category: TCacheCategory = 'aggregate'): T | Promise<T> {
85
+ if (this.stopped) throw new Error('Metrics cache is stopped');
86
+ const cache = this.caches[category];
87
+ const cached = cache.get(key);
88
+ if (cached && Date.now() - cached.timestamp < cached.ttl) return cached.data as T;
89
+ cache.delete(key);
90
+ const flightKey = JSON.stringify([category, key]);
91
+ const pending = this.inFlight.get(flightKey);
92
+ if (pending) return pending as Promise<T>;
93
+ if (this.activeComputations >= this.maxInFlight) throw new MetricsCapacityError();
94
+ const generation = this.generation;
95
+ this.activeComputations++;
96
+ let result: T | Promise<T>;
97
+ try { result = computeFn(); } catch (error) { this.activeComputations--; throw error; }
98
+ const remember = (data: T) => {
99
+ if (!this.stopped && generation === this.generation && ttl > 0) {
100
+ cache.set(key, { data, timestamp: Date.now(), ttl }, { ttl });
101
+ }
102
+ };
103
+ if (!(result instanceof Promise)) {
104
+ this.activeComputations--;
105
+ remember(result);
62
106
  return result;
63
107
  }
108
+ const run = result.then((data) => {
109
+ if (this.inFlight.get(flightKey) === run) remember(data);
110
+ return data;
111
+ }).finally(() => {
112
+ this.activeComputations--;
113
+ this.admitted.delete(run);
114
+ if (this.inFlight.get(flightKey) === run) this.inFlight.delete(flightKey);
115
+ });
116
+ this.admitted.add(run);
117
+ this.inFlight.set(flightKey, run);
118
+ return run;
64
119
  }
65
-
66
- /**
67
- * Invalidate a specific cache entry
68
- */
120
+
69
121
  public invalidate(key: string): void {
70
- this.cache.delete(key);
71
- this.inFlight.delete(key);
122
+ for (const category of ['aggregate', 'snapshot'] as const) {
123
+ this.caches[category].delete(key);
124
+ this.inFlight.delete(JSON.stringify([category, key]));
125
+ }
72
126
  }
73
-
74
- /**
75
- * Clear all cache entries
76
- */
127
+
77
128
  public clear(): void {
78
- this.cache.clear();
129
+ this.generation++;
130
+ this.caches.aggregate.clear();
131
+ this.caches.snapshot.clear();
79
132
  this.inFlight.clear();
133
+ // Invalidated work retains its admission slot until it actually settles.
80
134
  }
81
-
82
- /**
83
- * Get cache statistics
84
- */
85
- public getStats(): { size: number; keys: string[] } {
135
+
136
+ public start(): void { this.stopped = false; }
137
+
138
+ public async stop(): Promise<void> {
139
+ this.stopped = true;
140
+ this.clear();
141
+ await Promise.allSettled(this.admitted);
142
+ }
143
+
144
+ public getStats(): { size: number; keys: string[]; inFlight: number; estimatedBytes: number } {
86
145
  return {
87
- size: this.cache.size,
88
- keys: Array.from(this.cache.keys())
146
+ size: this.caches.aggregate.size + this.caches.snapshot.size,
147
+ keys: [...this.caches.aggregate.keys(), ...this.caches.snapshot.keys()],
148
+ inFlight: this.activeComputations,
149
+ estimatedBytes: this.caches.aggregate.calculatedSize + this.caches.snapshot.calculatedSize,
89
150
  };
90
151
  }
91
-
92
- /**
93
- * Clean up expired entries
94
- */
152
+
95
153
  public cleanup(): void {
96
- const now = Date.now();
97
- for (const [key, entry] of this.cache.entries()) {
98
- if (now - entry.timestamp > entry.ttl) {
99
- this.cache.delete(key);
100
- }
101
- }
154
+ this.caches.aggregate.purgeStale();
155
+ this.caches.snapshot.purgeStale();
102
156
  }
103
157
  }
@@ -100,6 +100,7 @@ export class MetricsManager {
100
100
  }
101
101
 
102
102
  public async start(): Promise<void> {
103
+ this.metricsCache.start();
103
104
  if (this.canPersistEmailTraffic()) {
104
105
  await this.loadEmailTrafficBuckets();
105
106
  await this.prunePersistedMetrics();
@@ -166,6 +167,7 @@ export class MetricsManager {
166
167
  }
167
168
 
168
169
  public async stop(): Promise<void> {
170
+ await this.metricsCache.stop();
169
171
  if (this.resetInterval) {
170
172
  clearInterval(this.resetInterval);
171
173
  this.resetInterval = undefined;
@@ -400,13 +402,13 @@ export class MetricsManager {
400
402
  limit: Math.min(Math.max(options.limit ?? MAX_SNAPSHOT_LIMIT, 1), MAX_SNAPSHOT_LIMIT),
401
403
  };
402
404
  const afterId = (normalizedOptions as typeof normalizedOptions & { afterId?: string }).afterId ?? '';
403
- const cacheKey = `activeConnectionSnapshots:${normalizedOptions.limit}:${normalizedOptions.routeId ?? ''}:${afterId}`;
405
+ const cacheKey = JSON.stringify(['activeConnectionSnapshots', normalizedOptions.limit, normalizedOptions.routeId ?? '', afterId]);
404
406
  return await this.metricsCache.get<plugins.smartproxy.IActiveConnectionSnapshot[]>(cacheKey, async () => {
405
407
  if (!this.dcRouter.smartProxy) {
406
408
  return [];
407
409
  }
408
410
  return this.dcRouter.smartProxy.getActiveConnectionSnapshots(normalizedOptions);
409
- }, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS);
411
+ }, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS, 'snapshot');
410
412
  }
411
413
 
412
414
  public async getActiveConnectionSnapshotPage(
@@ -417,13 +419,13 @@ export class MetricsManager {
417
419
  limit: Math.min(Math.max(options.limit ?? MAX_SNAPSHOT_LIMIT, 1), MAX_SNAPSHOT_LIMIT),
418
420
  };
419
421
  const afterId = normalizedOptions.afterId ?? '';
420
- const cacheKey = `activeConnectionSnapshotPage:${normalizedOptions.limit}:${normalizedOptions.routeId ?? ''}:${afterId}`;
422
+ const cacheKey = JSON.stringify(['activeConnectionSnapshotPage', normalizedOptions.limit, normalizedOptions.routeId ?? '', afterId]);
421
423
  return await this.metricsCache.get<plugins.smartproxy.IActiveConnectionSnapshotPage>(cacheKey, async () => {
422
424
  if (!this.dcRouter.smartProxy) {
423
425
  return { snapshots: [], hasMore: false, nextCursor: null };
424
426
  }
425
427
  return this.dcRouter.smartProxy.getActiveConnectionSnapshotPage(normalizedOptions);
426
- }, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS);
428
+ }, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS, 'snapshot');
427
429
  }
428
430
 
429
431
  // Get connection info from SmartProxy
package/ts/plugins.ts CHANGED
@@ -101,11 +101,13 @@ export {
101
101
  }
102
102
 
103
103
  // third party
104
+ import * as lruCache from 'lru-cache';
104
105
  import ipaddr from 'ipaddr.js';
105
106
  import * as uuid from 'uuid';
106
107
  import webpush from 'web-push';
107
108
 
108
109
  export {
110
+ lruCache,
109
111
  ipaddr,
110
112
  uuid,
111
113
  webpush,
@@ -115,12 +115,12 @@ export class RemoteIngressManager {
115
115
  listenPortsUdp: doc.listenPortsUdp,
116
116
  enabled: doc.enabled,
117
117
  autoDerivePorts: doc.autoDerivePorts,
118
- performance: doc.performance,
118
+ performance: doc.performance ?? undefined,
119
119
  egress: this.normalizeEgressPolicy(doc.egress),
120
120
  tags: doc.tags,
121
- publicIp: doc.publicIp,
122
- publicIpV6: doc.publicIpV6,
123
- mailHostname: doc.mailHostname,
121
+ publicIp: doc.publicIp ?? undefined,
122
+ publicIpV6: doc.publicIpV6 ?? undefined,
123
+ mailHostname: doc.mailHostname ?? undefined,
124
124
  createdAt: doc.createdAt,
125
125
  updatedAt: doc.updatedAt,
126
126
  };
@@ -200,7 +200,22 @@ export class RemoteIngressManager {
200
200
  }
201
201
  doc.updatedAt = Date.now();
202
202
  doc.updatedBy = updatedBy;
203
- await doc.save();
203
+ if (doc.creationStatus === 'new') {
204
+ await doc.save();
205
+ } else {
206
+ const persisted = await RemoteIngressHubSettingsDoc.atomicUpdate({ settingsId: doc.settingsId }, {
207
+ $set: {
208
+ updatedAt: doc.updatedAt,
209
+ updatedBy: doc.updatedBy,
210
+ ...(hasOwn(normalized, 'enabled') ? { enabled: doc.enabled! } : {}),
211
+ ...(hasOwn(normalized, 'tunnelPort') ? { tunnelPort: doc.tunnelPort! } : {}),
212
+ ...(hasOwn(updates, 'hubDomain') ? { hubDomain: doc.hubDomain! } : {}),
213
+ ...(hasOwn(updates, 'performance') && doc.performance != null ? { performance: doc.performance } : {}),
214
+ },
215
+ ...(hasOwn(updates, 'performance') && doc.performance == null ? { $unset: { performance: true } } : {}),
216
+ });
217
+ if (persisted.matchedCount !== 1) throw new Error('RemoteIngress hub settings disappeared during update');
218
+ }
204
219
 
205
220
  this.hubSettings = this.toHubSettings(doc);
206
221
  return this.getHubSettings();
@@ -415,8 +430,27 @@ export class RemoteIngressManager {
415
430
 
416
431
  const doc = await RemoteIngressEdgeDoc.findById(id);
417
432
  if (doc) {
418
- Object.assign(doc, edge);
419
- await doc.save();
433
+ const persisted = await RemoteIngressEdgeDoc.atomicUpdate({ id }, {
434
+ $set: {
435
+ name: edge.name,
436
+ listenPorts: edge.listenPorts,
437
+ autoDerivePorts: edge.autoDerivePorts,
438
+ enabled: edge.enabled,
439
+ tags: edge.tags,
440
+ updatedAt: edge.updatedAt,
441
+ ...(edge.performance !== undefined ? { performance: edge.performance } : {}),
442
+ ...(edge.egress !== undefined ? { egress: edge.egress } : {}),
443
+ ...(updates.publicIp !== undefined && edge.publicIp !== undefined ? { publicIp: edge.publicIp } : {}),
444
+ ...(updates.publicIpV6 !== undefined && edge.publicIpV6 !== undefined ? { publicIpV6: edge.publicIpV6 } : {}),
445
+ ...(updates.mailHostname !== undefined && edge.mailHostname !== undefined ? { mailHostname: edge.mailHostname } : {}),
446
+ },
447
+ $unset: {
448
+ ...(updates.publicIp !== undefined && edge.publicIp === undefined ? { publicIp: true } : {}),
449
+ ...(updates.publicIpV6 !== undefined && edge.publicIpV6 === undefined ? { publicIpV6: true } : {}),
450
+ ...(updates.mailHostname !== undefined && edge.mailHostname === undefined ? { mailHostname: true } : {}),
451
+ },
452
+ });
453
+ if (persisted.matchedCount !== 1) throw new Error('RemoteIngress edge disappeared during update');
420
454
  }
421
455
  this.edges.set(id, edge);
422
456
  this.notifyEdgesChanged(`edge updated: ${edge.name}`);
@@ -740,7 +774,7 @@ export class RemoteIngressManager {
740
774
  enabled: doc.enabled ?? false,
741
775
  tunnelPort: doc.tunnelPort ?? defaultTunnelPort,
742
776
  hubDomain: doc.hubDomain || undefined,
743
- performance: doc.performance,
777
+ performance: doc.performance ?? undefined,
744
778
  updatedAt: doc.updatedAt,
745
779
  updatedBy: doc.updatedBy,
746
780
  };
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import { logger } from '../logger.js';
3
3
  import type { DcRouter } from '../classes.dcrouter.js';
4
+ import { MetricsCache } from '../monitoring/classes.metricscache.js';
4
5
  import { AuthenticationEventDoc } from '../db/documents/classes.authentication-event.doc.js';
5
6
  import type {
6
7
  IAuthenticationEvent,
@@ -29,16 +30,23 @@ export class AuthenticationEventManager {
29
30
  private recentEvents = new Map<string, IAuthenticationEvent>();
30
31
  private flushInFlight?: Promise<void>;
31
32
  private pruneInFlight?: Promise<number>;
32
- private windowStatsInFlight = new Map<string, Promise<IAuthenticationWindowStats>>();
33
+ private windowStatsCache = new MetricsCache(1_000, { maxAggregateEntries: 8, maxInFlight: 8 });
34
+ private lastPersistenceAvailable?: boolean;
33
35
 
34
36
  constructor(private dcRouterRef: DcRouter) {}
35
37
 
38
+ public start(): void { this.windowStatsCache.start(); }
39
+
40
+ /** Drain observer reads; MetricsManager still owns the final persistence flush. */
41
+ public async stop(): Promise<void> { await this.windowStatsCache.stop(); }
42
+
36
43
  private canPersist(): boolean {
37
44
  return this.dcRouterRef.options.dbConfig?.enabled !== false
38
45
  && Boolean(this.dcRouterRef.dcRouterDb?.isReady());
39
46
  }
40
47
 
41
48
  private rememberEvent(eventArg: IAuthenticationEvent): void {
49
+ this.windowStatsCache.clear();
42
50
  this.pendingEvents.set(eventArg.id, eventArg);
43
51
  this.recentEvents.set(eventArg.id, eventArg);
44
52
  while (this.pendingEvents.size > MAX_IN_MEMORY_EVENTS) {
@@ -108,7 +116,11 @@ export class AuthenticationEventManager {
108
116
 
109
117
  public flushBuffered(): Promise<void> {
110
118
  if (this.flushInFlight) return this.flushInFlight;
111
- const run = this.flushBufferedUnlocked();
119
+ const mutates = this.canPersist() && this.pendingEvents.size > 0;
120
+ if (mutates) this.windowStatsCache.clear();
121
+ const run = this.flushBufferedUnlocked().finally(() => {
122
+ if (mutates) this.windowStatsCache.clear();
123
+ });
112
124
  this.flushInFlight = run;
113
125
  run.then(
114
126
  () => { if (this.flushInFlight === run) this.flushInFlight = undefined; },
@@ -119,9 +131,10 @@ export class AuthenticationEventManager {
119
131
 
120
132
  public pruneBefore(cutoffArg: number): Promise<number> {
121
133
  if (this.pruneInFlight) return this.pruneInFlight;
122
- const run = this.canPersist()
134
+ this.windowStatsCache.clear();
135
+ const run = (this.canPersist()
123
136
  ? AuthenticationEventDoc.pruneBefore(cutoffArg)
124
- : Promise.resolve(0);
137
+ : Promise.resolve(0)).finally(() => { this.windowStatsCache.clear(); });
125
138
  this.pruneInFlight = run;
126
139
  run.then(
127
140
  () => { if (this.pruneInFlight === run) this.pruneInFlight = undefined; },
@@ -151,12 +164,6 @@ export class AuthenticationEventManager {
151
164
  return this.getInMemoryWindow(cutoff, limitArg);
152
165
  }
153
166
 
154
- try {
155
- await this.flushBuffered();
156
- } catch (error) {
157
- logger.log('warn', `Unable to flush authentication event buffer: ${(error as Error).message}`);
158
- }
159
-
160
167
  try {
161
168
  const durable = await AuthenticationEventDoc.getWindowSummary(cutoff, limitArg);
162
169
  const buffered = [...this.pendingEvents.values()]
@@ -190,7 +197,7 @@ export class AuthenticationEventManager {
190
197
  }
191
198
  }
192
199
 
193
- public getWindowStats(
200
+ public async getWindowStats(
194
201
  windowMsArg = 24 * 60 * 60 * 1000,
195
202
  limitArg = 100,
196
203
  ): Promise<IAuthenticationWindowStats> {
@@ -200,21 +207,25 @@ export class AuthenticationEventManager {
200
207
  if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) {
201
208
  return Promise.reject(new Error('Authentication event limit must be from 1 to 500'));
202
209
  }
203
- const key = `${windowMsArg}:${limitArg}`;
204
- const existing = this.windowStatsInFlight.get(key);
205
- if (existing) return existing;
206
- const run = this.getWindowStatsUnlocked(windowMsArg, limitArg);
207
- this.windowStatsInFlight.set(key, run);
208
- run.then(
209
- () => { if (this.windowStatsInFlight.get(key) === run) this.windowStatsInFlight.delete(key); },
210
- () => { if (this.windowStatsInFlight.get(key) === run) this.windowStatsInFlight.delete(key); },
210
+ const persistenceAvailable = this.canPersist();
211
+ if (this.lastPersistenceAvailable !== persistenceAvailable) this.windowStatsCache.clear();
212
+ this.lastPersistenceAvailable = persistenceAvailable;
213
+ // Drain before entering the shared read boundary. Recording authentication itself never waits here.
214
+ try {
215
+ await this.flushBuffered();
216
+ } catch (error) {
217
+ logger.log('warn', `Unable to flush authentication event buffer: ${(error as Error).message}`);
218
+ }
219
+ return await this.windowStatsCache.get(
220
+ `${windowMsArg}:${limitArg}`,
221
+ () => this.getWindowStatsUnlocked(windowMsArg, limitArg),
211
222
  );
212
- return run;
213
223
  }
214
224
 
215
225
  /** Test-only reset for the DcRouter-owned in-process buffers. */
216
226
  public async resetForTests(): Promise<void> {
217
227
  await this.flushInFlight?.catch(() => undefined);
228
+ this.windowStatsCache.clear();
218
229
  this.pendingEvents.clear();
219
230
  this.recentEvents.clear();
220
231
  }