@serve.zone/dcrouter 18.9.3 → 18.9.5

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 +43 -11
  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 +7 -6
  26. package/readme.hints.md +84 -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 +45 -10
  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;
@@ -92,6 +92,25 @@ function mailEdgeEligibilitySignature(result: IMailEdgeEligibilityResult): strin
92
92
  return JSON.stringify({ supported: result.supported, edges });
93
93
  }
94
94
 
95
+ function mailDnsRevisionTopologySignature(revision: IEmailDomainDnsRevision): string {
96
+ // Evidence freshness is checked by edge eligibility. A refreshed heartbeat
97
+ // does not change DNS topology or require another SPF contraction pass.
98
+ return JSON.stringify({
99
+ selector: revision.selector,
100
+ edgeIdentities: revision.edgeIdentities.map((identity) => ({
101
+ edgeId: identity.edgeId,
102
+ hostname: identity.hostname,
103
+ ipv4: identity.ipv4 ?? undefined,
104
+ ipv6: identity.ipv6 ?? undefined,
105
+ })),
106
+ mxSlots: revision.mxSlots.map((slot) => ({
107
+ edgeId: slot.edgeId,
108
+ hostname: slot.hostname,
109
+ priority: slot.priority,
110
+ })),
111
+ });
112
+ }
113
+
95
114
  function normalizeMx(value: string): string {
96
115
  const [priority, exchange = ''] = value.trim().split(/\s+/);
97
116
  return `${Number(priority)} ${normalizeHostname(exchange)}`;
@@ -1484,16 +1503,11 @@ export class MailDnsSync {
1484
1503
  )
1485
1504
  : MAIL_DNS_RETRY_INTERVAL_MS;
1486
1505
  const previousActive = plan.doc.reconciliation?.activeRevision;
1487
- const topologyChanged = Boolean(previousActive) && JSON.stringify({
1488
- selector: previousActive?.selector,
1489
- edgeIdentities: previousActive?.edgeIdentities,
1490
- mxSlots: previousActive?.mxSlots,
1491
- }) !== JSON.stringify({
1492
- selector: plan.revision.selector,
1493
- edgeIdentities: plan.revision.edgeIdentities,
1494
- mxSlots: plan.revision.mxSlots,
1495
- });
1506
+ const topologyChanged = previousActive != null
1507
+ && mailDnsRevisionTopologySignature(previousActive)
1508
+ !== mailDnsRevisionTopologySignature(plan.revision);
1496
1509
  const now = new Date().toISOString();
1510
+ const promotedMaterial = canActivate ? plan.doc.pendingDkim : undefined;
1497
1511
  if (canActivate && plan.doc.pendingDkim) {
1498
1512
  this.promotePendingDkim(plan.doc, now);
1499
1513
  }
@@ -1534,7 +1548,28 @@ export class MailDnsSync {
1534
1548
  lastCheckedAt: now,
1535
1549
  };
1536
1550
  plan.doc.updatedAt = now;
1537
- await plan.doc.save();
1551
+ if (promotedMaterial) {
1552
+ // Publish the replacement signer and clear its staged material in one write.
1553
+ const result = await EmailDomainDoc.atomicUpdate(
1554
+ { id: plan.doc.id, pendingDkim: { $eq: promotedMaterial } },
1555
+ {
1556
+ $set: {
1557
+ activeDkim: plan.doc.activeDkim!,
1558
+ retiringDkim: plan.doc.retiringDkim!,
1559
+ dkim: plan.doc.dkim,
1560
+ reconciliation: plan.doc.reconciliation,
1561
+ dnsStatus: plan.doc.dnsStatus,
1562
+ updatedAt: now,
1563
+ },
1564
+ $unset: { pendingDkim: true },
1565
+ },
1566
+ );
1567
+ if (result.matchedCount !== 1) {
1568
+ throw new Error('Staged DKIM material changed before promotion');
1569
+ }
1570
+ } else {
1571
+ await plan.doc.save();
1572
+ }
1538
1573
  if (canActivate && topologyChanged) {
1539
1574
  // The transition SPF intentionally authorizes old + new identities. Run
1540
1575
  // 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
  };