@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.
- 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 +43 -11
- 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 +7 -6
- package/readme.hints.md +84 -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 +45 -10
- 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
package/ts/dns/manager.dns.ts
CHANGED
|
@@ -836,7 +836,15 @@ export class DnsManager {
|
|
|
836
836
|
this.providerClients.delete(id);
|
|
837
837
|
}
|
|
838
838
|
doc.updatedAt = Date.now();
|
|
839
|
-
await
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
88
|
-
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
|
-
|
|
97
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
};
|