@serve.zone/dcrouter 17.2.4 → 17.4.0

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 (113) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +2024 -1758
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +11 -1
  5. package/dist_ts/classes.dcrouter.js +63 -3
  6. package/dist_ts/config/classes.api-token-manager.d.ts +20 -2
  7. package/dist_ts/config/classes.api-token-manager.js +201 -6
  8. package/dist_ts/config/classes.gateway-client-manager.d.ts +36 -2
  9. package/dist_ts/config/classes.gateway-client-manager.js +391 -27
  10. package/dist_ts/db/documents/classes.api-token.doc.d.ts +2 -1
  11. package/dist_ts/db/documents/classes.api-token.doc.js +8 -2
  12. package/dist_ts/db/documents/classes.cached.email.d.ts +10 -1
  13. package/dist_ts/db/documents/classes.cached.email.js +27 -2
  14. package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +25 -0
  15. package/dist_ts/db/documents/classes.gateway-client.doc.js +73 -2
  16. package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +55 -0
  17. package/dist_ts/db/documents/classes.ops-config-event.doc.js +296 -0
  18. package/dist_ts/db/documents/index.d.ts +1 -0
  19. package/dist_ts/db/documents/index.js +2 -1
  20. package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +52 -0
  21. package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +609 -0
  22. package/dist_ts/dns/index.d.ts +1 -0
  23. package/dist_ts/dns/index.js +2 -1
  24. package/dist_ts/dns/manager.dns.d.ts +19 -0
  25. package/dist_ts/dns/manager.dns.js +25 -1
  26. package/dist_ts/email/classes.accepted-email-spool.d.ts +8 -1
  27. package/dist_ts/email/classes.accepted-email-spool.js +93 -1
  28. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +7 -1
  29. package/dist_ts/email/classes.smartmta-storage-manager.js +46 -1
  30. package/dist_ts/email/smartmta-storage-policy.js +3 -1
  31. package/dist_ts/monitoring/classes.ops-event-manager.d.ts +32 -0
  32. package/dist_ts/monitoring/classes.ops-event-manager.js +71 -0
  33. package/dist_ts/monitoring/index.d.ts +1 -0
  34. package/dist_ts/monitoring/index.js +2 -1
  35. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  36. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  37. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +1 -0
  38. package/dist_ts/opsserver/handlers/certificate.handler.js +35 -5
  39. package/dist_ts/opsserver/handlers/config-event.handler.d.ts +12 -0
  40. package/dist_ts/opsserver/handlers/config-event.handler.js +47 -0
  41. package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +1 -0
  42. package/dist_ts/opsserver/handlers/dns-record.handler.js +82 -3
  43. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +6 -0
  44. package/dist_ts/opsserver/handlers/email-ops.handler.js +40 -9
  45. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +9 -1
  46. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +714 -162
  47. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  48. package/dist_ts/opsserver/handlers/index.js +2 -1
  49. package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +25 -0
  50. package/dist_ts/opsserver/helpers/gateway-client-auth.js +168 -0
  51. package/dist_ts/plugins.d.ts +2 -1
  52. package/dist_ts/plugins.js +3 -2
  53. package/dist_ts_apiclient/classes.gatewayclient.d.ts +13 -1
  54. package/dist_ts_apiclient/classes.gatewayclient.js +18 -2
  55. package/dist_ts_interfaces/data/config-events.d.ts +34 -0
  56. package/dist_ts_interfaces/data/config-events.js +8 -0
  57. package/dist_ts_interfaces/data/index.d.ts +1 -0
  58. package/dist_ts_interfaces/data/index.js +2 -1
  59. package/dist_ts_interfaces/data/route-management.d.ts +28 -1
  60. package/dist_ts_interfaces/data/route-management.js +3 -1
  61. package/dist_ts_interfaces/data/workhoster.d.ts +1 -0
  62. package/dist_ts_interfaces/requests/config-events.d.ts +35 -0
  63. package/dist_ts_interfaces/requests/config-events.js +4 -0
  64. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  65. package/dist_ts_interfaces/requests/index.js +2 -1
  66. package/dist_ts_interfaces/requests/realtime.d.ts +1 -1
  67. package/dist_ts_interfaces/requests/realtime.js +2 -1
  68. package/dist_ts_migrations/index.js +148 -9
  69. package/dist_ts_web/00_commitinfo_data.js +1 -1
  70. package/dist_ts_web/appstate/config-events.d.ts +19 -0
  71. package/dist_ts_web/appstate/config-events.js +68 -0
  72. package/dist_ts_web/appstate/runtime.js +7 -1
  73. package/dist_ts_web/appstate.d.ts +1 -0
  74. package/dist_ts_web/appstate.js +2 -1
  75. package/dist_ts_web/elements/ops-platform-events.d.ts +21 -0
  76. package/dist_ts_web/elements/ops-platform-events.js +361 -0
  77. package/dist_ts_web/elements/ops-view-logs.d.ts +4 -0
  78. package/dist_ts_web/elements/ops-view-logs.js +96 -9
  79. package/package.json +4 -4
  80. package/readme.plan.md +22 -0
  81. package/ts/00_commitinfo_data.ts +1 -1
  82. package/ts/classes.dcrouter.ts +72 -2
  83. package/ts/config/classes.api-token-manager.ts +237 -4
  84. package/ts/config/classes.gateway-client-manager.ts +463 -19
  85. package/ts/db/documents/classes.api-token.doc.ts +8 -1
  86. package/ts/db/documents/classes.cached.email.ts +21 -1
  87. package/ts/db/documents/classes.gateway-client.doc.ts +86 -0
  88. package/ts/db/documents/classes.ops-config-event.doc.ts +257 -0
  89. package/ts/db/documents/index.ts +1 -0
  90. package/ts/dns/classes.gateway-route-dns-reconciler.ts +801 -0
  91. package/ts/dns/index.ts +1 -0
  92. package/ts/dns/manager.dns.ts +36 -0
  93. package/ts/email/classes.accepted-email-spool.ts +111 -0
  94. package/ts/email/classes.smartmta-storage-manager.ts +57 -1
  95. package/ts/email/smartmta-storage-policy.ts +2 -0
  96. package/ts/monitoring/classes.ops-event-manager.ts +99 -0
  97. package/ts/monitoring/index.ts +2 -1
  98. package/ts/opsserver/classes.opsserver.ts +2 -0
  99. package/ts/opsserver/handlers/certificate.handler.ts +51 -4
  100. package/ts/opsserver/handlers/config-event.handler.ts +62 -0
  101. package/ts/opsserver/handlers/dns-record.handler.ts +101 -2
  102. package/ts/opsserver/handlers/email-ops.handler.ts +42 -8
  103. package/ts/opsserver/handlers/gatewayclient.handler.ts +804 -136
  104. package/ts/opsserver/handlers/index.ts +1 -0
  105. package/ts/opsserver/helpers/gateway-client-auth.ts +198 -0
  106. package/ts/plugins.ts +2 -0
  107. package/ts_apiclient/classes.gatewayclient.ts +45 -0
  108. package/ts_web/00_commitinfo_data.ts +1 -1
  109. package/ts_web/appstate/config-events.ts +98 -0
  110. package/ts_web/appstate/runtime.ts +8 -1
  111. package/ts_web/appstate.ts +1 -0
  112. package/ts_web/elements/ops-platform-events.ts +315 -0
  113. package/ts_web/elements/ops-view-logs.ts +84 -7
@@ -0,0 +1,801 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type { DcRouter } from '../classes.dcrouter.js';
3
+ import type { DnsRecordDoc, DomainDoc } from '../db/index.js';
4
+ import type * as interfaces from '../../ts_interfaces/index.js';
5
+ import { logger } from '../logger.js';
6
+ import { normalizeGatewayHostname } from '../opsserver/helpers/gateway-client-auth.js';
7
+
8
+ export const GATEWAY_ROUTE_DNS_MANAGED_BY = 'gateway-route-dns';
9
+ const GATEWAY_ROUTE_DNS_TTL = 3600;
10
+ const RECONCILE_INTERVAL_MS = 60_000;
11
+ const RECONCILE_COOLDOWN_MS = 15_000;
12
+
13
+ type TGatewayDnsResult = plugins.servezoneInterfaces.data.IGatewayRouteDnsResult;
14
+
15
+ export interface IGatewayRouteDnsReconcileOptions {
16
+ externalKey: string;
17
+ gatewayClientId: string;
18
+ hostname?: string;
19
+ mode?: plugins.servezoneInterfaces.data.TGatewayRouteDnsMode;
20
+ delete?: boolean;
21
+ }
22
+
23
+ interface IGatewayDnsReconcileExecutionOptions {
24
+ respectCooldown?: boolean;
25
+ providerZoneRefreshes?: Map<string, Promise<IProviderZoneRefreshResult>>;
26
+ }
27
+
28
+ interface IProviderZoneRefreshResult {
29
+ success: boolean;
30
+ message?: string;
31
+ }
32
+
33
+ interface IGatewayDnsLastAttempt {
34
+ workKey: string;
35
+ generation: number;
36
+ attemptedAt: number;
37
+ result?: TGatewayDnsResult;
38
+ }
39
+
40
+ interface IGatewayDnsLatestIntent {
41
+ workKey: string;
42
+ generation: number;
43
+ promise: Promise<TGatewayDnsResult>;
44
+ }
45
+
46
+ export interface IGatewayDnsActiveClaim {
47
+ gatewayClientId: string;
48
+ routeExternalKeys: string[];
49
+ }
50
+
51
+ export function getActiveGatewayDnsClaims(
52
+ dcRouterRefArg: Pick<DcRouter, 'routeConfigManager'>,
53
+ hostnameArg: string,
54
+ ): IGatewayDnsActiveClaim[] {
55
+ const hostname = normalizeGatewayHostname(hostnameArg);
56
+ const claims = new Map<string, string[]>();
57
+ const routes = dcRouterRefArg.routeConfigManager?.getMergedRoutes().routes || [];
58
+ for (const routeArg of routes) {
59
+ const metadata = routeArg.metadata;
60
+ if (!routeArg.enabled || metadata?.ownerType !== 'gatewayClient'
61
+ || metadata.gatewayDnsMode !== 'reconcile' || !metadata.gatewayClientId
62
+ || !metadata.externalKey) continue;
63
+ const domains = routeArg.route.match?.domains;
64
+ const firstDomain = Array.isArray(domains)
65
+ ? domains[0]
66
+ : typeof domains === 'string'
67
+ ? domains.split(',')[0]
68
+ : undefined;
69
+ if (!firstDomain) continue;
70
+ try {
71
+ if (normalizeGatewayHostname(firstDomain) !== hostname) continue;
72
+ } catch {
73
+ continue;
74
+ }
75
+ const gatewayClientId = metadata.gatewayClientId.trim().toLowerCase();
76
+ const keys = claims.get(gatewayClientId) || [];
77
+ keys.push(metadata.externalKey);
78
+ claims.set(gatewayClientId, keys);
79
+ }
80
+ return Array.from(claims.entries())
81
+ .map(([gatewayClientId, routeExternalKeys]) => ({
82
+ gatewayClientId,
83
+ routeExternalKeys: routeExternalKeys.sort((left, right) => left.localeCompare(right)),
84
+ }))
85
+ .sort((left, right) => left.gatewayClientId.localeCompare(right.gatewayClientId));
86
+ }
87
+
88
+ export class GatewayRouteDnsReconciler {
89
+ private interval?: ReturnType<typeof setInterval> & { unref?: () => void };
90
+ private fullReconcilePromise?: Promise<void>;
91
+ private lastAttemptByHostname = new Map<string, IGatewayDnsLastAttempt>();
92
+ private latestIntentByHostname = new Map<string, IGatewayDnsLatestIntent>();
93
+ private hostnameChains = new Map<string, Promise<void>>();
94
+ private intentGeneration = 0;
95
+
96
+ constructor(private dcRouterRef: DcRouter) {}
97
+
98
+ public async start(): Promise<void> {
99
+ await this.runFullReconcile(true);
100
+ this.interval = setInterval(() => {
101
+ this.runFullReconcile(false).catch((error: unknown) => {
102
+ logger.log('warn', `Gateway route DNS reconcile failed: ${(error as Error).message}`);
103
+ });
104
+ }, RECONCILE_INTERVAL_MS);
105
+ this.interval.unref?.();
106
+ }
107
+
108
+ public async stop(): Promise<void> {
109
+ if (this.interval) clearInterval(this.interval);
110
+ this.interval = undefined;
111
+ await this.fullReconcilePromise?.catch(() => undefined);
112
+ }
113
+
114
+ public async reconcileRoute(optionsArg: IGatewayRouteDnsReconcileOptions): Promise<TGatewayDnsResult> {
115
+ return await this.reconcileRouteWithExecution(optionsArg, {});
116
+ }
117
+
118
+ private async reconcileRouteWithExecution(
119
+ optionsArg: IGatewayRouteDnsReconcileOptions,
120
+ executionArg: IGatewayDnsReconcileExecutionOptions,
121
+ ): Promise<TGatewayDnsResult> {
122
+ const checkedAt = Date.now();
123
+ const mode = optionsArg.mode || 'skip';
124
+ if (mode === 'skip') {
125
+ return { success: true, retryable: false, mode, status: 'skipped', checkedAt };
126
+ }
127
+ if (!optionsArg.hostname) {
128
+ return { success: true, retryable: false, mode, status: 'not-applicable', checkedAt };
129
+ }
130
+ const hostname = normalizeGatewayHostname(optionsArg.hostname);
131
+ const targetIp = optionsArg.delete ? undefined : this.getDesiredTargetIp();
132
+ const workKey = this.buildWorkKey(optionsArg, hostname, mode, targetIp);
133
+ const latestIntent = this.latestIntentByHostname.get(hostname);
134
+ if (latestIntent?.workKey === workKey) return await latestIntent.promise;
135
+
136
+ const lastAttempt = this.lastAttemptByHostname.get(hostname);
137
+ if (
138
+ executionArg.respectCooldown
139
+ && lastAttempt?.workKey === workKey
140
+ && lastAttempt.result
141
+ && checkedAt - lastAttempt.attemptedAt < RECONCILE_COOLDOWN_MS
142
+ ) {
143
+ return lastAttempt.result;
144
+ }
145
+
146
+ const generation = ++this.intentGeneration;
147
+ this.lastAttemptByHostname.set(hostname, { workKey, generation, attemptedAt: checkedAt });
148
+ const run = this.withHostnameLock(hostname, async () => {
149
+ return await this.reconcileRouteUnderHostnameLock(
150
+ optionsArg,
151
+ hostname,
152
+ mode,
153
+ checkedAt,
154
+ targetIp,
155
+ executionArg.providerZoneRefreshes,
156
+ );
157
+ });
158
+ this.latestIntentByHostname.set(hostname, {
159
+ workKey,
160
+ generation,
161
+ promise: run,
162
+ });
163
+ try {
164
+ const result = await run;
165
+ const currentAttempt = this.lastAttemptByHostname.get(hostname);
166
+ if (currentAttempt?.generation === generation) currentAttempt.result = result;
167
+ return result;
168
+ } finally {
169
+ const currentIntent = this.latestIntentByHostname.get(hostname);
170
+ if (currentIntent?.generation === generation) {
171
+ this.latestIntentByHostname.delete(hostname);
172
+ }
173
+ }
174
+ }
175
+
176
+ private async reconcileRouteUnderHostnameLock(
177
+ optionsArg: IGatewayRouteDnsReconcileOptions,
178
+ hostname: string,
179
+ mode: Exclude<plugins.servezoneInterfaces.data.TGatewayRouteDnsMode, 'skip'>,
180
+ checkedAt: number,
181
+ targetIp: string | undefined,
182
+ providerZoneRefreshes?: Map<string, Promise<IProviderZoneRefreshResult>>,
183
+ ): Promise<TGatewayDnsResult> {
184
+ const claimOwnerId = this.buildClaimOwnerId(optionsArg.gatewayClientId, hostname);
185
+ if (optionsArg.delete) {
186
+ return await this.deleteOwnedRecords(
187
+ claimOwnerId,
188
+ optionsArg.externalKey,
189
+ optionsArg.gatewayClientId,
190
+ hostname,
191
+ mode,
192
+ checkedAt,
193
+ providerZoneRefreshes,
194
+ );
195
+ }
196
+
197
+ if (!this.getActiveClaimsForHostname(hostname).some((claimArg) => {
198
+ return claimArg.gatewayClientId === optionsArg.gatewayClientId.trim().toLowerCase();
199
+ })) {
200
+ return {
201
+ success: true,
202
+ retryable: false,
203
+ mode,
204
+ status: 'not-applicable',
205
+ hostname,
206
+ targetIp,
207
+ checkedAt,
208
+ };
209
+ }
210
+
211
+ const ipFamily = targetIp ? plugins.net.isIP(targetIp) : 0;
212
+ if (!targetIp || !ipFamily) {
213
+ return {
214
+ success: false,
215
+ retryable: true,
216
+ mode,
217
+ status: 'target-invalid',
218
+ hostname,
219
+ checkedAt,
220
+ message: 'Gateway DNS target requires a valid proxyIps[0], publicIp, or detectedPublicIp',
221
+ };
222
+ }
223
+ const recordType = ipFamily === 6 ? 'AAAA' : 'A';
224
+ const dnsManager = this.dcRouterRef.dnsManager;
225
+ if (!dnsManager) {
226
+ return {
227
+ success: false,
228
+ retryable: true,
229
+ mode,
230
+ status: 'zone-unavailable',
231
+ hostname,
232
+ targetIp,
233
+ recordType,
234
+ checkedAt,
235
+ message: 'DnsManager is not initialized',
236
+ };
237
+ }
238
+ const zone = await dnsManager.findDomainForFqdn(hostname);
239
+ if (!zone) {
240
+ return {
241
+ success: false,
242
+ retryable: false,
243
+ mode,
244
+ status: 'zone-unmanaged',
245
+ hostname,
246
+ targetIp,
247
+ recordType,
248
+ checkedAt,
249
+ message: `No managed DNS zone covers ${hostname}`,
250
+ };
251
+ }
252
+ if (zone.source === 'provider' && !zone.providerId) {
253
+ return this.failureForZone(mode, checkedAt, hostname, targetIp, recordType, zone, 'zone-unavailable', 'Managed provider zone has no providerId');
254
+ }
255
+ if (zone.source === 'provider') {
256
+ const syncResult = await this.refreshProviderZone(zone.id, providerZoneRefreshes);
257
+ if (!syncResult.success) {
258
+ return this.failureForZone(
259
+ mode,
260
+ checkedAt,
261
+ hostname,
262
+ targetIp,
263
+ recordType,
264
+ zone,
265
+ 'zone-unavailable',
266
+ syncResult.message || 'Provider zone synchronization failed',
267
+ );
268
+ }
269
+ }
270
+
271
+ const records = (await dnsManager.listRecordsForDomain(zone.id)).filter((recordArg) => {
272
+ return this.isAddressRecord(recordArg)
273
+ && this.normalizeStoredHostname(recordArg.name) === hostname;
274
+ });
275
+ const competing = records.find((recordArg) => recordArg.managedBy
276
+ && (recordArg.managedBy !== GATEWAY_ROUTE_DNS_MANAGED_BY || recordArg.managedOwnerId !== claimOwnerId));
277
+ if (competing) {
278
+ return this.failureForZone(
279
+ mode,
280
+ checkedAt,
281
+ hostname,
282
+ targetIp,
283
+ recordType,
284
+ zone,
285
+ 'record-conflict',
286
+ `DNS hostname ${hostname} is managed by ${competing.managedBy}:${competing.managedOwnerId || 'unknown'}`,
287
+ records,
288
+ );
289
+ }
290
+
291
+ const desired = records.find((recordArg) => recordArg.type === recordType
292
+ && recordArg.value === targetIp
293
+ && recordArg.ttl === GATEWAY_ROUTE_DNS_TTL
294
+ && recordArg.proxied === false);
295
+ if (mode === 'observe') {
296
+ const satisfied = records.some((recordArg) => recordArg.type === recordType && recordArg.value === targetIp);
297
+ return {
298
+ success: satisfied,
299
+ retryable: !satisfied,
300
+ mode,
301
+ status: satisfied ? 'observed-satisfied' : 'record-missing',
302
+ hostname,
303
+ targetIp,
304
+ recordType,
305
+ zoneId: zone.id,
306
+ zoneName: zone.name,
307
+ nameservers: zone.nameservers,
308
+ records: records.map((recordArg) => this.toEvidence(recordArg)),
309
+ checkedAt,
310
+ };
311
+ }
312
+
313
+ const ownerRecords = records.filter((recordArg) => recordArg.managedBy === GATEWAY_ROUTE_DNS_MANAGED_BY
314
+ && recordArg.managedOwnerId === claimOwnerId);
315
+ const manualRecords = records.filter((recordArg) => !recordArg.managedBy);
316
+ const stableKey = this.buildManagedRecordKey(claimOwnerId);
317
+ if (
318
+ records.length === 1
319
+ && manualRecords.length === 1
320
+ && desired === manualRecords[0]
321
+ ) {
322
+ const adopted = await dnsManager.adoptRecordManagement({
323
+ id: desired.id,
324
+ name: hostname,
325
+ type: recordType,
326
+ value: targetIp,
327
+ ttl: GATEWAY_ROUTE_DNS_TTL,
328
+ proxied: false,
329
+ managedBy: GATEWAY_ROUTE_DNS_MANAGED_BY,
330
+ managedOwnerId: claimOwnerId,
331
+ managedRecordKey: stableKey,
332
+ });
333
+ if (!adopted.success) {
334
+ return this.failureForZone(
335
+ mode,
336
+ checkedAt,
337
+ hostname,
338
+ targetIp,
339
+ recordType,
340
+ zone,
341
+ 'mutation-failed',
342
+ adopted.message || 'Failed to adopt existing DNS record',
343
+ records,
344
+ );
345
+ }
346
+ const adoptedRecords = (await dnsManager.listRecordsForDomain(zone.id)).filter((recordArg) => {
347
+ return this.isAddressRecord(recordArg)
348
+ && this.normalizeStoredHostname(recordArg.name) === hostname;
349
+ });
350
+ return this.successForZone('managed-updated', mode, checkedAt, hostname, targetIp, recordType, zone, adoptedRecords);
351
+ }
352
+ if (
353
+ records.length === 1
354
+ && ownerRecords.length === 1
355
+ && desired === ownerRecords[0]
356
+ && desired.managedRecordKey === stableKey
357
+ ) {
358
+ return this.successForZone('managed-unchanged', mode, checkedAt, hostname, targetIp, recordType, zone, records);
359
+ }
360
+
361
+ const overwrittenRecords = manualRecords.map((recordArg) => this.toEvidence(recordArg));
362
+ const rollbackRecords = records.map((recordArg) => this.snapshotRecord(recordArg));
363
+ try {
364
+ for (const record of records) {
365
+ const result = await dnsManager.deleteRecord(record.id);
366
+ if (!result.success) throw new Error(result.message || `Failed to delete DNS record ${record.id}`);
367
+ }
368
+ const created = await dnsManager.createRecord({
369
+ domainId: zone.id,
370
+ name: hostname,
371
+ type: recordType,
372
+ value: targetIp,
373
+ ttl: GATEWAY_ROUTE_DNS_TTL,
374
+ proxied: false,
375
+ createdBy: GATEWAY_ROUTE_DNS_MANAGED_BY,
376
+ managedBy: GATEWAY_ROUTE_DNS_MANAGED_BY,
377
+ managedOwnerId: claimOwnerId,
378
+ managedRecordKey: stableKey,
379
+ });
380
+ if (!created.success) throw new Error(created.message || `Failed to create ${recordType} record for ${hostname}`);
381
+ const finalRecords = (await dnsManager.listRecordsForDomain(zone.id)).filter((recordArg) => {
382
+ return this.isAddressRecord(recordArg) && this.normalizeStoredHostname(recordArg.name) === hostname;
383
+ });
384
+ if (manualRecords.length > 0) {
385
+ await this.recordManualClaimEvent(
386
+ zone,
387
+ hostname,
388
+ targetIp,
389
+ claimOwnerId,
390
+ this.getContributingRouteKeys(optionsArg.gatewayClientId, hostname),
391
+ manualRecords,
392
+ );
393
+ }
394
+ const status = manualRecords.length > 0
395
+ ? 'managed-overwrote-manual'
396
+ : ownerRecords.length > 0
397
+ ? 'managed-updated'
398
+ : 'managed-created';
399
+ return {
400
+ ...this.successForZone(status, mode, checkedAt, hostname, targetIp, recordType, zone, finalRecords),
401
+ ...(overwrittenRecords.length ? { overwrittenRecords } : {}),
402
+ };
403
+ } catch (error) {
404
+ let status: TGatewayDnsResult['status'] = 'mutation-failed';
405
+ let message = (error as Error).message;
406
+ try {
407
+ await this.restoreRecords(zone, hostname, rollbackRecords);
408
+ } catch (restoreError) {
409
+ status = 'compensation-failed';
410
+ message = `${message}; DNS restore failed: ${(restoreError as Error).message}`;
411
+ logger.log('error', `Gateway DNS compensation failed for ${hostname}: ${message}`);
412
+ }
413
+ return this.failureForZone(
414
+ mode,
415
+ checkedAt,
416
+ hostname,
417
+ targetIp,
418
+ recordType,
419
+ zone,
420
+ status,
421
+ message,
422
+ );
423
+ }
424
+ }
425
+
426
+ public async runFullReconcile(force = false): Promise<void> {
427
+ if (this.fullReconcilePromise) return await this.fullReconcilePromise;
428
+ const run = this.reconcileAll(force);
429
+ this.fullReconcilePromise = run;
430
+ try {
431
+ await run;
432
+ } finally {
433
+ if (this.fullReconcilePromise === run) this.fullReconcilePromise = undefined;
434
+ }
435
+ }
436
+
437
+ private async reconcileAll(force: boolean): Promise<void> {
438
+ const routes = this.dcRouterRef.routeConfigManager?.getMergedRoutes().routes || [];
439
+ const activeOwners = new Set<string>();
440
+ const providerZoneRefreshes = new Map<string, Promise<IProviderZoneRefreshResult>>();
441
+ for (const route of routes) {
442
+ const externalKey = route.metadata?.externalKey;
443
+ if (!route.enabled || route.metadata?.ownerType !== 'gatewayClient'
444
+ || route.metadata.gatewayDnsMode !== 'reconcile' || !externalKey) continue;
445
+ const hostname = this.getRouteHostname(route.route);
446
+ if (!hostname) continue;
447
+ const gatewayClientId = route.metadata.gatewayClientId;
448
+ if (!gatewayClientId) continue;
449
+ const claimOwnerId = this.buildClaimOwnerId(gatewayClientId, hostname);
450
+ const isFirstRouteForClaim = !activeOwners.has(claimOwnerId);
451
+ activeOwners.add(claimOwnerId);
452
+ if (!isFirstRouteForClaim) continue;
453
+ const result = await this.reconcileRouteWithExecution(
454
+ { externalKey, gatewayClientId, hostname, mode: 'reconcile' },
455
+ { respectCooldown: !force, providerZoneRefreshes },
456
+ );
457
+ if (!result.success) {
458
+ logger.log(
459
+ 'warn',
460
+ `Gateway route DNS reconcile returned ${result.status} for ${hostname}: ${result.message || 'no detail'}`,
461
+ );
462
+ }
463
+ }
464
+
465
+ const dnsManager = this.dcRouterRef.dnsManager;
466
+ if (!dnsManager) return;
467
+ for (const record of await (await import('../db/documents/classes.dns-record.doc.js')).DnsRecordDoc.findAll()) {
468
+ if (record.managedBy !== GATEWAY_ROUTE_DNS_MANAGED_BY || !record.managedOwnerId) continue;
469
+ if (activeOwners.has(record.managedOwnerId)) continue;
470
+ const hostname = this.normalizeStoredHostname(record.name);
471
+ if (!hostname) continue;
472
+ await this.withHostnameLock(hostname, async () => {
473
+ const liveClaimOwnerIds = this.getActiveClaimsForHostname(hostname).map((claimArg) => {
474
+ return this.buildClaimOwnerId(claimArg.gatewayClientId, hostname);
475
+ });
476
+ if (liveClaimOwnerIds.includes(record.managedOwnerId!)) return;
477
+ const current = await (await import('../db/documents/classes.dns-record.doc.js')).DnsRecordDoc.findById(record.id);
478
+ if (!current || current.managedBy !== GATEWAY_ROUTE_DNS_MANAGED_BY
479
+ || current.managedOwnerId !== record.managedOwnerId
480
+ || this.normalizeStoredHostname(current.name) !== hostname) return;
481
+ const result = await dnsManager.deleteRecord(record.id);
482
+ if (!result.success) {
483
+ logger.log('warn', `Failed to delete orphan gateway DNS record ${record.id}: ${result.message || 'unknown error'}`);
484
+ }
485
+ });
486
+ }
487
+ }
488
+
489
+ private async deleteOwnedRecords(
490
+ claimOwnerIdArg: string,
491
+ excludedExternalKeyArg: string,
492
+ gatewayClientIdArg: string,
493
+ hostnameArg: string,
494
+ modeArg: plugins.servezoneInterfaces.data.TGatewayRouteDnsMode,
495
+ checkedAtArg: number,
496
+ providerZoneRefreshes?: Map<string, Promise<IProviderZoneRefreshResult>>,
497
+ ): Promise<TGatewayDnsResult> {
498
+ const dnsManager = this.dcRouterRef.dnsManager;
499
+ if (!dnsManager) {
500
+ return { success: false, retryable: true, mode: modeArg, status: 'zone-unavailable', hostname: hostnameArg, checkedAt: checkedAtArg };
501
+ }
502
+ const zone = await dnsManager.findDomainForFqdn(hostnameArg);
503
+ if (!zone) {
504
+ return { success: true, retryable: false, mode: modeArg, status: 'managed-deleted', hostname: hostnameArg, checkedAt: checkedAtArg };
505
+ }
506
+ if (zone.source === 'provider') {
507
+ const syncResult = await this.refreshProviderZone(zone.id, providerZoneRefreshes);
508
+ if (!syncResult.success) {
509
+ return this.failureForZone(
510
+ modeArg,
511
+ checkedAtArg,
512
+ hostnameArg,
513
+ undefined,
514
+ undefined,
515
+ zone,
516
+ 'zone-unavailable',
517
+ syncResult.message || 'Provider zone synchronization failed',
518
+ );
519
+ }
520
+ }
521
+ const remainingRouteKeys = this.getContributingRouteKeys(
522
+ gatewayClientIdArg,
523
+ hostnameArg,
524
+ excludedExternalKeyArg,
525
+ );
526
+ const owned = (await dnsManager.listRecordsForDomain(zone.id)).filter((recordArg) => {
527
+ return this.normalizeStoredHostname(recordArg.name) === hostnameArg
528
+ && recordArg.managedBy === GATEWAY_ROUTE_DNS_MANAGED_BY
529
+ && recordArg.managedOwnerId === claimOwnerIdArg;
530
+ });
531
+ if (modeArg === 'observe') {
532
+ return this.successForZone('observed-satisfied', modeArg, checkedAtArg, hostnameArg, undefined, undefined, zone, owned);
533
+ }
534
+ if (remainingRouteKeys.length > 0) {
535
+ return this.successForZone('managed-unchanged', modeArg, checkedAtArg, hostnameArg, undefined, undefined, zone, owned);
536
+ }
537
+ for (const record of owned) {
538
+ const result = await dnsManager.deleteRecord(record.id);
539
+ if (!result.success) {
540
+ return this.failureForZone(modeArg, checkedAtArg, hostnameArg, undefined, undefined, zone, 'mutation-failed', result.message || 'DNS cleanup failed', owned);
541
+ }
542
+ }
543
+ return this.successForZone('managed-deleted', modeArg, checkedAtArg, hostnameArg, undefined, undefined, zone, []);
544
+ }
545
+
546
+ private getDesiredTargetIp(): string | undefined {
547
+ return this.dcRouterRef.options.proxyIps?.[0]
548
+ || this.dcRouterRef.options.publicIp
549
+ || this.dcRouterRef.detectedPublicIp
550
+ || undefined;
551
+ }
552
+
553
+ private getRouteHostname(routeArg: interfaces.data.IDcRouterRouteConfig): string | undefined {
554
+ const domains = routeArg.match?.domains;
555
+ const first = Array.isArray(domains) ? domains[0] : typeof domains === 'string' ? domains.split(',')[0] : undefined;
556
+ return first ? normalizeGatewayHostname(first) : undefined;
557
+ }
558
+
559
+ private buildWorkKey(
560
+ optionsArg: IGatewayRouteDnsReconcileOptions,
561
+ hostnameArg: string,
562
+ modeArg: Exclude<plugins.servezoneInterfaces.data.TGatewayRouteDnsMode, 'skip'>,
563
+ targetIpArg: string | undefined,
564
+ ): string {
565
+ return JSON.stringify({
566
+ hostname: hostnameArg,
567
+ gatewayClientId: optionsArg.gatewayClientId.trim().toLowerCase(),
568
+ externalKey: optionsArg.externalKey,
569
+ mode: modeArg,
570
+ delete: optionsArg.delete === true,
571
+ targetIp: targetIpArg || null,
572
+ claims: this.getActiveClaimsForHostname(hostnameArg),
573
+ });
574
+ }
575
+
576
+ private async refreshProviderZone(
577
+ zoneIdArg: string,
578
+ providerZoneRefreshesArg?: Map<string, Promise<IProviderZoneRefreshResult>>,
579
+ ): Promise<IProviderZoneRefreshResult> {
580
+ const dnsManager = this.dcRouterRef.dnsManager;
581
+ if (!dnsManager) return { success: false, message: 'DnsManager is not initialized' };
582
+ if (!providerZoneRefreshesArg) return await dnsManager.syncDomain(zoneIdArg);
583
+ let refresh = providerZoneRefreshesArg.get(zoneIdArg);
584
+ if (!refresh) {
585
+ refresh = dnsManager.syncDomain(zoneIdArg);
586
+ providerZoneRefreshesArg.set(zoneIdArg, refresh);
587
+ }
588
+ return await refresh;
589
+ }
590
+
591
+ public buildClaimOwnerId(gatewayClientIdArg: string, hostnameArg: string): string {
592
+ const gatewayClientId = gatewayClientIdArg.trim().toLowerCase();
593
+ const hostname = normalizeGatewayHostname(hostnameArg);
594
+ return `gateway-client:${gatewayClientId}:${hostname}`;
595
+ }
596
+
597
+ public getActiveClaimsForHostname(hostnameArg: string): IGatewayDnsActiveClaim[] {
598
+ return getActiveGatewayDnsClaims(this.dcRouterRef, hostnameArg);
599
+ }
600
+
601
+ private buildManagedRecordKey(claimOwnerIdArg: string): string {
602
+ return `${GATEWAY_ROUTE_DNS_MANAGED_BY}:${Buffer.from(claimOwnerIdArg, 'utf8').toString('base64url')}`;
603
+ }
604
+
605
+ private getContributingRouteKeys(
606
+ gatewayClientIdArg: string,
607
+ hostnameArg: string,
608
+ excludedExternalKeyArg?: string,
609
+ ): string[] {
610
+ const claim = this.getActiveClaimsForHostname(hostnameArg)
611
+ .find((claimArg) => claimArg.gatewayClientId === gatewayClientIdArg.trim().toLowerCase());
612
+ return (claim?.routeExternalKeys || []).filter((externalKeyArg) => externalKeyArg !== excludedExternalKeyArg);
613
+ }
614
+
615
+ private isAddressRecord(recordArg: DnsRecordDoc): boolean {
616
+ return recordArg.type === 'A' || recordArg.type === 'AAAA' || recordArg.type === 'CNAME';
617
+ }
618
+
619
+ private normalizeStoredHostname(hostnameArg: string): string {
620
+ try {
621
+ return normalizeGatewayHostname(hostnameArg);
622
+ } catch {
623
+ return '';
624
+ }
625
+ }
626
+
627
+ private toEvidence(recordArg: DnsRecordDoc): plugins.servezoneInterfaces.data.IGatewayRouteDnsRecord {
628
+ return {
629
+ id: recordArg.id,
630
+ type: recordArg.type as 'A' | 'AAAA' | 'CNAME',
631
+ name: recordArg.name,
632
+ value: recordArg.value,
633
+ ttl: recordArg.ttl,
634
+ proxied: recordArg.proxied,
635
+ managed: recordArg.managedBy === GATEWAY_ROUTE_DNS_MANAGED_BY,
636
+ };
637
+ }
638
+
639
+ private snapshotRecord(recordArg: DnsRecordDoc) {
640
+ return {
641
+ name: recordArg.name,
642
+ type: recordArg.type,
643
+ value: recordArg.value,
644
+ ttl: recordArg.ttl,
645
+ proxied: recordArg.proxied,
646
+ createdBy: recordArg.createdBy,
647
+ managedBy: recordArg.managedBy,
648
+ managedOwnerId: recordArg.managedOwnerId,
649
+ managedRecordKey: recordArg.managedRecordKey,
650
+ };
651
+ }
652
+
653
+ private async restoreRecords(
654
+ zoneArg: DomainDoc,
655
+ hostnameArg: string,
656
+ snapshotsArg: ReturnType<GatewayRouteDnsReconciler['snapshotRecord']>[],
657
+ ): Promise<void> {
658
+ const dnsManager = this.dcRouterRef.dnsManager;
659
+ if (!dnsManager) throw new Error('DnsManager is not initialized');
660
+ const current = (await dnsManager.listRecordsForDomain(zoneArg.id)).filter((recordArg) => {
661
+ return this.normalizeStoredHostname(recordArg.name) === hostnameArg && this.isAddressRecord(recordArg);
662
+ });
663
+ for (const record of current) {
664
+ const deleted = await dnsManager.deleteRecord(record.id);
665
+ if (!deleted.success) throw new Error(deleted.message || `Failed to remove partial DNS record ${record.id}`);
666
+ }
667
+ for (const snapshot of snapshotsArg) {
668
+ const created = await dnsManager.createRecord({ domainId: zoneArg.id, ...snapshot });
669
+ if (!created.success) throw new Error(created.message || `Failed to restore ${snapshot.type} ${snapshot.name}`);
670
+ }
671
+ const restored = (await dnsManager.listRecordsForDomain(zoneArg.id)).filter((recordArg) => {
672
+ return this.normalizeStoredHostname(recordArg.name) === hostnameArg && this.isAddressRecord(recordArg);
673
+ });
674
+ const normalize = (valueArg: ReturnType<GatewayRouteDnsReconciler['snapshotRecord']>) => JSON.stringify({
675
+ name: this.normalizeStoredHostname(valueArg.name),
676
+ type: valueArg.type,
677
+ value: valueArg.value,
678
+ ttl: valueArg.ttl,
679
+ proxied: valueArg.proxied,
680
+ managedBy: valueArg.managedBy,
681
+ managedOwnerId: valueArg.managedOwnerId,
682
+ managedRecordKey: valueArg.managedRecordKey,
683
+ });
684
+ const expected = snapshotsArg.map(normalize).sort();
685
+ const actual = restored.map((recordArg) => normalize(this.snapshotRecord(recordArg))).sort();
686
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
687
+ throw new Error('Restored DNS records do not match the pre-mutation snapshot');
688
+ }
689
+ }
690
+
691
+ private async withHostnameLock<T>(hostnameArg: string, taskArg: () => Promise<T>): Promise<T> {
692
+ const hostname = normalizeGatewayHostname(hostnameArg);
693
+ const previous = this.hostnameChains.get(hostname) || Promise.resolve();
694
+ const run = previous.catch(() => undefined).then(taskArg);
695
+ const settled = run.then(
696
+ () => undefined,
697
+ () => undefined,
698
+ );
699
+ this.hostnameChains.set(hostname, settled);
700
+ try {
701
+ return await run;
702
+ } finally {
703
+ if (this.hostnameChains.get(hostname) === settled) {
704
+ this.hostnameChains.delete(hostname);
705
+ }
706
+ }
707
+ }
708
+
709
+ private async recordManualClaimEvent(
710
+ zoneArg: DomainDoc,
711
+ hostnameArg: string,
712
+ targetIpArg: string,
713
+ claimOwnerIdArg: string,
714
+ contributingRouteKeysArg: string[],
715
+ recordsArg: DnsRecordDoc[],
716
+ ): Promise<void> {
717
+ if (this.dcRouterRef.opsEventManager) {
718
+ try {
719
+ await this.dcRouterRef.opsEventManager.recordEvent({
720
+ severity: 'error',
721
+ category: 'dns-conflict',
722
+ title: 'Gateway route DNS automation claimed a manual hostname',
723
+ detail: `Gateway DNS owner ${claimOwnerIdArg} replaced ${recordsArg.length} manual A/AAAA/CNAME record(s) for ${hostnameArg}.`,
724
+ context: {
725
+ domain: zoneArg.name,
726
+ recordName: hostnameArg,
727
+ previousValue: recordsArg.map((recordArg) => `${recordArg.type} ${recordArg.value}`).join(', '),
728
+ newValue: targetIpArg,
729
+ routeRef: contributingRouteKeysArg.join(', ') || claimOwnerIdArg,
730
+ actor: GATEWAY_ROUTE_DNS_MANAGED_BY,
731
+ },
732
+ });
733
+ } catch (error) {
734
+ logger.log('warn', `Failed to record gateway DNS conflict event for ${hostnameArg}: ${(error as Error).message}`);
735
+ }
736
+ }
737
+ try {
738
+ this.dcRouterRef.opsServer?.invalidateRealtime?.('configEvents', {
739
+ reason: 'gateway-dns-manual-claim',
740
+ });
741
+ } catch (error) {
742
+ logger.log('warn', `Failed to invalidate config events after gateway DNS claim for ${hostnameArg}: ${(error as Error).message}`);
743
+ }
744
+ }
745
+
746
+ private successForZone(
747
+ statusArg: TGatewayDnsResult['status'],
748
+ modeArg: TGatewayDnsResult['mode'],
749
+ checkedAtArg: number,
750
+ hostnameArg: string,
751
+ targetIpArg: string | undefined,
752
+ recordTypeArg: 'A' | 'AAAA' | undefined,
753
+ zoneArg: DomainDoc,
754
+ recordsArg: DnsRecordDoc[],
755
+ ): TGatewayDnsResult {
756
+ return {
757
+ success: true,
758
+ retryable: false,
759
+ mode: modeArg,
760
+ status: statusArg,
761
+ hostname: hostnameArg,
762
+ targetIp: targetIpArg,
763
+ recordType: recordTypeArg,
764
+ zoneId: zoneArg.id,
765
+ zoneName: zoneArg.name,
766
+ nameservers: zoneArg.nameservers,
767
+ records: recordsArg.map((recordArg) => this.toEvidence(recordArg)),
768
+ checkedAt: checkedAtArg,
769
+ };
770
+ }
771
+
772
+ private failureForZone(
773
+ modeArg: TGatewayDnsResult['mode'],
774
+ checkedAtArg: number,
775
+ hostnameArg: string,
776
+ targetIpArg: string | undefined,
777
+ recordTypeArg: 'A' | 'AAAA' | undefined,
778
+ zoneArg: DomainDoc,
779
+ statusArg: TGatewayDnsResult['status'],
780
+ messageArg: string,
781
+ recordsArg: DnsRecordDoc[] = [],
782
+ ): TGatewayDnsResult {
783
+ return {
784
+ success: false,
785
+ retryable: statusArg === 'zone-unavailable'
786
+ || statusArg === 'mutation-failed'
787
+ || statusArg === 'compensation-failed',
788
+ mode: modeArg,
789
+ status: statusArg,
790
+ hostname: hostnameArg,
791
+ targetIp: targetIpArg,
792
+ recordType: recordTypeArg,
793
+ zoneId: zoneArg.id,
794
+ zoneName: zoneArg.name,
795
+ nameservers: zoneArg.nameservers,
796
+ records: recordsArg.map((recordArg) => this.toEvidence(recordArg)),
797
+ checkedAt: checkedAtArg,
798
+ message: messageArg,
799
+ };
800
+ }
801
+ }