@serve.zone/dcrouter 13.5.0 → 13.7.1

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 (136) hide show
  1. package/dist_serve/bundle.js +1705 -1365
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +2 -5
  4. package/dist_ts/classes.dcrouter.js +41 -10
  5. package/dist_ts/db/documents/classes.dns-provider.doc.d.ts +22 -0
  6. package/dist_ts/db/documents/classes.dns-provider.doc.js +134 -0
  7. package/dist_ts/db/documents/classes.dns-record.doc.d.ts +21 -0
  8. package/dist_ts/db/documents/classes.dns-record.doc.js +143 -0
  9. package/dist_ts/db/documents/classes.domain.doc.d.ts +22 -0
  10. package/dist_ts/db/documents/classes.domain.doc.js +146 -0
  11. package/dist_ts/db/documents/index.d.ts +3 -0
  12. package/dist_ts/db/documents/index.js +5 -1
  13. package/dist_ts/dns/index.d.ts +2 -0
  14. package/dist_ts/dns/index.js +3 -0
  15. package/dist_ts/dns/manager.dns.d.ts +227 -0
  16. package/dist_ts/dns/manager.dns.js +747 -0
  17. package/dist_ts/dns/providers/cloudflare.provider.d.ts +21 -0
  18. package/dist_ts/dns/providers/cloudflare.provider.js +106 -0
  19. package/dist_ts/dns/providers/factory.d.ts +23 -0
  20. package/dist_ts/dns/providers/factory.js +38 -0
  21. package/dist_ts/dns/providers/index.d.ts +3 -0
  22. package/dist_ts/dns/providers/index.js +4 -0
  23. package/dist_ts/dns/providers/interfaces.d.ts +54 -0
  24. package/dist_ts/dns/providers/interfaces.js +2 -0
  25. package/dist_ts/opsserver/classes.opsserver.d.ts +3 -0
  26. package/dist_ts/opsserver/classes.opsserver.js +7 -1
  27. package/dist_ts/opsserver/handlers/config.handler.js +11 -2
  28. package/dist_ts/opsserver/handlers/dns-provider.handler.d.ts +16 -0
  29. package/dist_ts/opsserver/handlers/dns-provider.handler.js +119 -0
  30. package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +13 -0
  31. package/dist_ts/opsserver/handlers/dns-record.handler.js +98 -0
  32. package/dist_ts/opsserver/handlers/domain.handler.d.ts +13 -0
  33. package/dist_ts/opsserver/handlers/domain.handler.js +124 -0
  34. package/dist_ts/opsserver/handlers/index.d.ts +3 -0
  35. package/dist_ts/opsserver/handlers/index.js +4 -1
  36. package/dist_ts_interfaces/data/dns-provider.d.ts +112 -0
  37. package/dist_ts_interfaces/data/dns-provider.js +27 -0
  38. package/dist_ts_interfaces/data/dns-record.d.ts +40 -0
  39. package/dist_ts_interfaces/data/dns-record.js +2 -0
  40. package/dist_ts_interfaces/data/domain.d.ts +34 -0
  41. package/dist_ts_interfaces/data/domain.js +2 -0
  42. package/dist_ts_interfaces/data/index.d.ts +3 -0
  43. package/dist_ts_interfaces/data/index.js +4 -1
  44. package/dist_ts_interfaces/data/route-management.d.ts +1 -1
  45. package/dist_ts_interfaces/requests/dns-providers.d.ts +117 -0
  46. package/dist_ts_interfaces/requests/dns-providers.js +2 -0
  47. package/dist_ts_interfaces/requests/dns-records.d.ts +89 -0
  48. package/dist_ts_interfaces/requests/dns-records.js +2 -0
  49. package/dist_ts_interfaces/requests/domains.d.ts +118 -0
  50. package/dist_ts_interfaces/requests/domains.js +2 -0
  51. package/dist_ts_interfaces/requests/index.d.ts +3 -0
  52. package/dist_ts_interfaces/requests/index.js +4 -1
  53. package/dist_ts_web/00_commitinfo_data.js +1 -1
  54. package/dist_ts_web/appstate.d.ts +72 -0
  55. package/dist_ts_web/appstate.js +308 -6
  56. package/dist_ts_web/elements/access/ops-view-apitokens.js +1 -1
  57. package/dist_ts_web/elements/access/ops-view-users.js +1 -1
  58. package/dist_ts_web/elements/domains/dns-provider-form.d.ts +58 -0
  59. package/dist_ts_web/elements/domains/dns-provider-form.js +268 -0
  60. package/dist_ts_web/elements/domains/index.d.ts +5 -0
  61. package/dist_ts_web/elements/domains/index.js +6 -0
  62. package/dist_ts_web/elements/{ops-view-certificates.d.ts → domains/ops-view-certificates.d.ts} +1 -1
  63. package/dist_ts_web/elements/{ops-view-certificates.js → domains/ops-view-certificates.js} +5 -5
  64. package/dist_ts_web/elements/domains/ops-view-dns.d.ts +17 -0
  65. package/dist_ts_web/elements/domains/ops-view-dns.js +304 -0
  66. package/dist_ts_web/elements/domains/ops-view-domains.d.ts +18 -0
  67. package/dist_ts_web/elements/domains/ops-view-domains.js +361 -0
  68. package/dist_ts_web/elements/domains/ops-view-providers.d.ts +21 -0
  69. package/dist_ts_web/elements/domains/ops-view-providers.js +316 -0
  70. package/dist_ts_web/elements/email/ops-view-email-security.js +1 -1
  71. package/dist_ts_web/elements/email/ops-view-emails.js +1 -1
  72. package/dist_ts_web/elements/index.d.ts +1 -1
  73. package/dist_ts_web/elements/index.js +2 -2
  74. package/dist_ts_web/elements/network/ops-view-network-activity.js +1 -1
  75. package/dist_ts_web/elements/network/ops-view-networktargets.js +1 -1
  76. package/dist_ts_web/elements/network/ops-view-remoteingress.js +1 -1
  77. package/dist_ts_web/elements/network/ops-view-routes.js +1 -1
  78. package/dist_ts_web/elements/network/ops-view-sourceprofiles.js +1 -1
  79. package/dist_ts_web/elements/network/ops-view-targetprofiles.js +1 -1
  80. package/dist_ts_web/elements/network/ops-view-vpn.js +1 -1
  81. package/dist_ts_web/elements/ops-dashboard.js +14 -5
  82. package/dist_ts_web/elements/ops-view-logs.js +1 -1
  83. package/dist_ts_web/elements/overview/ops-view-config.js +3 -3
  84. package/dist_ts_web/elements/overview/ops-view-overview.js +1 -1
  85. package/dist_ts_web/elements/security/ops-view-security-authentication.js +1 -1
  86. package/dist_ts_web/elements/security/ops-view-security-blocked.js +1 -1
  87. package/dist_ts_web/elements/security/ops-view-security-overview.js +1 -1
  88. package/dist_ts_web/router.d.ts +1 -1
  89. package/dist_ts_web/router.js +4 -2
  90. package/package.json +2 -2
  91. package/ts/00_commitinfo_data.ts +1 -1
  92. package/ts/classes.dcrouter.ts +46 -17
  93. package/ts/db/documents/classes.dns-provider.doc.ts +63 -0
  94. package/ts/db/documents/classes.dns-record.doc.ts +62 -0
  95. package/ts/db/documents/classes.domain.doc.ts +66 -0
  96. package/ts/db/documents/index.ts +5 -0
  97. package/ts/dns/index.ts +2 -0
  98. package/ts/dns/manager.dns.ts +869 -0
  99. package/ts/dns/providers/cloudflare.provider.ts +131 -0
  100. package/ts/dns/providers/factory.ts +48 -0
  101. package/ts/dns/providers/index.ts +3 -0
  102. package/ts/dns/providers/interfaces.ts +67 -0
  103. package/ts/opsserver/classes.opsserver.ts +6 -0
  104. package/ts/opsserver/handlers/config.handler.ts +10 -1
  105. package/ts/opsserver/handlers/dns-provider.handler.ts +159 -0
  106. package/ts/opsserver/handlers/dns-record.handler.ts +127 -0
  107. package/ts/opsserver/handlers/domain.handler.ts +161 -0
  108. package/ts/opsserver/handlers/index.ts +4 -1
  109. package/ts_web/00_commitinfo_data.ts +1 -1
  110. package/ts_web/appstate.ts +403 -5
  111. package/ts_web/elements/access/ops-view-apitokens.ts +1 -1
  112. package/ts_web/elements/access/ops-view-users.ts +1 -1
  113. package/ts_web/elements/domains/dns-provider-form.ts +216 -0
  114. package/ts_web/elements/domains/index.ts +5 -0
  115. package/ts_web/elements/{ops-view-certificates.ts → domains/ops-view-certificates.ts} +4 -4
  116. package/ts_web/elements/domains/ops-view-dns.ts +273 -0
  117. package/ts_web/elements/domains/ops-view-domains.ts +335 -0
  118. package/ts_web/elements/domains/ops-view-providers.ts +284 -0
  119. package/ts_web/elements/email/ops-view-email-security.ts +1 -1
  120. package/ts_web/elements/email/ops-view-emails.ts +1 -1
  121. package/ts_web/elements/index.ts +1 -1
  122. package/ts_web/elements/network/ops-view-network-activity.ts +1 -1
  123. package/ts_web/elements/network/ops-view-networktargets.ts +1 -1
  124. package/ts_web/elements/network/ops-view-remoteingress.ts +1 -1
  125. package/ts_web/elements/network/ops-view-routes.ts +1 -1
  126. package/ts_web/elements/network/ops-view-sourceprofiles.ts +1 -1
  127. package/ts_web/elements/network/ops-view-targetprofiles.ts +1 -1
  128. package/ts_web/elements/network/ops-view-vpn.ts +1 -1
  129. package/ts_web/elements/ops-dashboard.ts +14 -4
  130. package/ts_web/elements/ops-view-logs.ts +1 -1
  131. package/ts_web/elements/overview/ops-view-config.ts +2 -2
  132. package/ts_web/elements/overview/ops-view-overview.ts +1 -1
  133. package/ts_web/elements/security/ops-view-security-authentication.ts +1 -1
  134. package/ts_web/elements/security/ops-view-security-blocked.ts +1 -1
  135. package/ts_web/elements/security/ops-view-security-overview.ts +1 -1
  136. package/ts_web/router.ts +3 -1
@@ -0,0 +1,869 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
3
+ import {
4
+ DnsProviderDoc,
5
+ DomainDoc,
6
+ DnsRecordDoc,
7
+ } from '../db/documents/index.js';
8
+ import type { IDcRouterOptions } from '../classes.dcrouter.js';
9
+ import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js';
10
+ import { createDnsProvider } from './providers/factory.js';
11
+ import type {
12
+ TDnsRecordType,
13
+ TDnsRecordSource,
14
+ } from '../../ts_interfaces/data/dns-record.js';
15
+ import type {
16
+ TDnsProviderType,
17
+ TDnsProviderCredentials,
18
+ IDnsProviderPublic,
19
+ IProviderDomainListing,
20
+ } from '../../ts_interfaces/data/dns-provider.js';
21
+
22
+ /**
23
+ * DnsManager — owns runtime DNS state on top of the embedded DnsServer.
24
+ *
25
+ * Responsibilities:
26
+ * - Load Domain/DnsRecord docs from the DB on start
27
+ * - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
28
+ * - Register manual-domain records with smartdns.DnsServer at startup
29
+ * - Provide CRUD methods used by OpsServer handlers (manual domains hit smartdns,
30
+ * provider domains hit the provider API)
31
+ * - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
32
+ *
33
+ * Provider-managed domains are NEVER served from the embedded DnsServer — the
34
+ * provider stays authoritative. We only mirror their records locally for the UI
35
+ * and to track providerRecordIds for updates / deletes.
36
+ */
37
+ export class DnsManager {
38
+ /**
39
+ * Reference to the active smartdns DnsServer (set by DcRouter once it exists).
40
+ * May be undefined if dnsScopes/dnsNsDomains aren't configured.
41
+ */
42
+ public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
43
+
44
+ /**
45
+ * Cached provider clients, keyed by DnsProviderDoc.id.
46
+ * Created lazily when a provider is first needed.
47
+ */
48
+ private providerClients = new Map<string, IDnsProviderClient>();
49
+
50
+ constructor(private options: IDcRouterOptions) {}
51
+
52
+ // ==========================================================================
53
+ // Lifecycle
54
+ // ==========================================================================
55
+
56
+ /**
57
+ * Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
58
+ * from legacy constructor config if (and only if) the DB is empty.
59
+ */
60
+ public async start(): Promise<void> {
61
+ logger.log('info', 'DnsManager: starting');
62
+ await this.seedFromConstructorConfigIfEmpty();
63
+ }
64
+
65
+ public async stop(): Promise<void> {
66
+ this.providerClients.clear();
67
+ this.dnsServer = undefined;
68
+ }
69
+
70
+ /**
71
+ * Wire the embedded DnsServer instance after it has been created by
72
+ * DcRouter.setupDnsWithSocketHandler(). After this, manual records loaded
73
+ * from the DB are registered with the server.
74
+ */
75
+ public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
76
+ this.dnsServer = dnsServer;
77
+ await this.applyManualDomainsToDnsServer();
78
+ }
79
+
80
+ // ==========================================================================
81
+ // First-boot seeding
82
+ // ==========================================================================
83
+
84
+ /**
85
+ * If no DomainDocs exist yet but the constructor has legacy DNS fields,
86
+ * seed them as `source: 'manual'` records. On subsequent boots (DB has
87
+ * entries), constructor config is ignored with a warning.
88
+ */
89
+ private async seedFromConstructorConfigIfEmpty(): Promise<void> {
90
+ const existingDomains = await DomainDoc.findAll();
91
+ const hasLegacyConfig =
92
+ (this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
93
+ (this.options.dnsRecords && this.options.dnsRecords.length > 0);
94
+
95
+ if (existingDomains.length > 0) {
96
+ if (hasLegacyConfig) {
97
+ logger.log(
98
+ 'warn',
99
+ 'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
100
+ 'Manage DNS via the Domains UI instead.',
101
+ );
102
+ }
103
+ return;
104
+ }
105
+
106
+ if (!hasLegacyConfig) {
107
+ return;
108
+ }
109
+
110
+ logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
111
+
112
+ const now = Date.now();
113
+ const seededDomains = new Map<string, DomainDoc>();
114
+
115
+ // Create one DomainDoc per dnsScope (these are the authoritative zones)
116
+ for (const scope of this.options.dnsScopes ?? []) {
117
+ const domain = new DomainDoc();
118
+ domain.id = plugins.uuid.v4();
119
+ domain.name = scope.toLowerCase();
120
+ domain.source = 'manual';
121
+ domain.authoritative = true;
122
+ domain.createdAt = now;
123
+ domain.updatedAt = now;
124
+ domain.createdBy = 'seed';
125
+ await domain.save();
126
+ seededDomains.set(domain.name, domain);
127
+ logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
128
+ }
129
+
130
+ // Map each legacy dnsRecord to its parent DomainDoc
131
+ for (const rec of this.options.dnsRecords ?? []) {
132
+ const parent = this.findParentDomain(rec.name, seededDomains);
133
+ if (!parent) {
134
+ logger.log(
135
+ 'warn',
136
+ `DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
137
+ );
138
+ continue;
139
+ }
140
+ const record = new DnsRecordDoc();
141
+ record.id = plugins.uuid.v4();
142
+ record.domainId = parent.id;
143
+ record.name = rec.name.toLowerCase();
144
+ record.type = rec.type as TDnsRecordType;
145
+ record.value = rec.value;
146
+ record.ttl = rec.ttl ?? 300;
147
+ record.source = 'manual';
148
+ record.createdAt = now;
149
+ record.updatedAt = now;
150
+ record.createdBy = 'seed';
151
+ await record.save();
152
+ }
153
+
154
+ logger.log(
155
+ 'info',
156
+ `DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
157
+ );
158
+ }
159
+
160
+ private findParentDomain(
161
+ recordName: string,
162
+ domains: Map<string, DomainDoc>,
163
+ ): DomainDoc | null {
164
+ const lower = recordName.toLowerCase().replace(/^\*\./, '');
165
+ let candidate: DomainDoc | null = null;
166
+ for (const [name, doc] of domains) {
167
+ if (lower === name || lower.endsWith(`.${name}`)) {
168
+ if (!candidate || name.length > candidate.name.length) {
169
+ candidate = doc;
170
+ }
171
+ }
172
+ }
173
+ return candidate;
174
+ }
175
+
176
+ // ==========================================================================
177
+ // Manual-domain DnsServer wiring
178
+ // ==========================================================================
179
+
180
+ /**
181
+ * Register all manual-domain records from the DB with the embedded DnsServer.
182
+ * Called once after attachDnsServer().
183
+ */
184
+ private async applyManualDomainsToDnsServer(): Promise<void> {
185
+ if (!this.dnsServer) {
186
+ return;
187
+ }
188
+ const allDomains = await DomainDoc.findAll();
189
+ const manualDomains = allDomains.filter((d) => d.source === 'manual');
190
+ let registered = 0;
191
+ for (const domain of manualDomains) {
192
+ const records = await DnsRecordDoc.findByDomainId(domain.id);
193
+ for (const rec of records) {
194
+ this.registerRecordWithDnsServer(rec);
195
+ registered++;
196
+ }
197
+ }
198
+ logger.log('info', `DnsManager: registered ${registered} manual DNS record(s) from DB`);
199
+ }
200
+
201
+ /**
202
+ * Register a single record with the embedded DnsServer. The handler closure
203
+ * captures the record fields, so updates require a re-register cycle.
204
+ */
205
+ private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
206
+ if (!this.dnsServer) return;
207
+ this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
208
+ if (question.name === rec.name && question.type === rec.type) {
209
+ return {
210
+ name: rec.name,
211
+ type: rec.type,
212
+ class: 'IN',
213
+ ttl: rec.ttl,
214
+ data: this.parseRecordData(rec.type, rec.value),
215
+ };
216
+ }
217
+ return null;
218
+ });
219
+ }
220
+
221
+ private parseRecordData(type: TDnsRecordType, value: string): any {
222
+ switch (type) {
223
+ case 'A':
224
+ case 'AAAA':
225
+ case 'CNAME':
226
+ case 'TXT':
227
+ case 'NS':
228
+ case 'CAA':
229
+ return value;
230
+ case 'MX': {
231
+ const [priorityStr, exchange] = value.split(' ');
232
+ return { priority: parseInt(priorityStr, 10), exchange };
233
+ }
234
+ case 'SOA': {
235
+ const parts = value.split(' ');
236
+ return {
237
+ mname: parts[0],
238
+ rname: parts[1],
239
+ serial: parseInt(parts[2], 10),
240
+ refresh: parseInt(parts[3], 10),
241
+ retry: parseInt(parts[4], 10),
242
+ expire: parseInt(parts[5], 10),
243
+ minimum: parseInt(parts[6], 10),
244
+ };
245
+ }
246
+ default:
247
+ return value;
248
+ }
249
+ }
250
+
251
+ // ==========================================================================
252
+ // Provider lookup (used by ACME DNS-01 + record CRUD)
253
+ // ==========================================================================
254
+
255
+ /**
256
+ * Get the provider client for a given DnsProviderDoc id, instantiating
257
+ * (and caching) it on first use.
258
+ */
259
+ public async getProviderClientById(providerId: string): Promise<IDnsProviderClient | null> {
260
+ const cached = this.providerClients.get(providerId);
261
+ if (cached) return cached;
262
+ const doc = await DnsProviderDoc.findById(providerId);
263
+ if (!doc) return null;
264
+ const client = createDnsProvider(doc.type, doc.credentials);
265
+ this.providerClients.set(providerId, client);
266
+ return client;
267
+ }
268
+
269
+ /**
270
+ * Find the IDnsProviderClient that owns the given FQDN (by walking up its
271
+ * labels to find a matching DomainDoc with `source === 'provider'`).
272
+ * Returns null if no provider claims this FQDN.
273
+ *
274
+ * Used by:
275
+ * - SmartAcme DNS-01 wiring in setupSmartProxy()
276
+ * - DnsRecordHandler when creating provider records
277
+ */
278
+ public async getProviderClientForDomain(fqdn: string): Promise<IDnsProviderClient | null> {
279
+ const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
280
+ const allDomains = await DomainDoc.findAll();
281
+ const providerDomains = allDomains
282
+ .filter((d) => d.source === 'provider' && d.providerId)
283
+ // longest-match wins
284
+ .sort((a, b) => b.name.length - a.name.length);
285
+
286
+ for (const domain of providerDomains) {
287
+ if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
288
+ return this.getProviderClientById(domain.providerId!);
289
+ }
290
+ }
291
+ return null;
292
+ }
293
+
294
+ /**
295
+ * True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
296
+ * to decide whether to wire SmartAcme with a DNS-01 handler.
297
+ */
298
+ public async hasAcmeCapableProvider(): Promise<boolean> {
299
+ const providers = await DnsProviderDoc.findAll();
300
+ return providers.length > 0;
301
+ }
302
+
303
+ /**
304
+ * Build an IConvenientDnsProvider that dispatches each ACME challenge to
305
+ * the right provider client (whichever provider type owns the parent zone),
306
+ * based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
307
+ * interface, so any registered provider implementation works.
308
+ * Returned object plugs directly into smartacme's Dns01Handler.
309
+ */
310
+ public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
311
+ const self = this;
312
+ const adapter = {
313
+ async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
314
+ const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
315
+ if (!client) {
316
+ throw new Error(
317
+ `DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
318
+ 'Add one in the Domains > Providers UI before issuing certificates.',
319
+ );
320
+ }
321
+ // Clean any leftover challenge records first to avoid duplicates.
322
+ try {
323
+ const existing = await client.listRecords(dnsChallenge.hostName);
324
+ for (const r of existing) {
325
+ if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
326
+ await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
327
+ }
328
+ }
329
+ } catch (err: unknown) {
330
+ logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
331
+ }
332
+ await client.createRecord(dnsChallenge.hostName, {
333
+ name: dnsChallenge.hostName,
334
+ type: 'TXT',
335
+ value: dnsChallenge.challenge,
336
+ ttl: 120,
337
+ });
338
+ },
339
+ async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
340
+ const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
341
+ if (!client) {
342
+ // The domain may have been removed; nothing to clean up.
343
+ return;
344
+ }
345
+ try {
346
+ const existing = await client.listRecords(dnsChallenge.hostName);
347
+ for (const r of existing) {
348
+ if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
349
+ await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
350
+ }
351
+ }
352
+ } catch (err: unknown) {
353
+ logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
354
+ }
355
+ },
356
+ async isDomainSupported(domain: string): Promise<boolean> {
357
+ const client = await self.getProviderClientForDomain(domain);
358
+ return !!client;
359
+ },
360
+ };
361
+ return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
362
+ }
363
+
364
+ // ==========================================================================
365
+ // Provider CRUD (used by DnsProviderHandler)
366
+ // ==========================================================================
367
+
368
+ public async listProviders(): Promise<IDnsProviderPublic[]> {
369
+ const docs = await DnsProviderDoc.findAll();
370
+ return docs.map((d) => this.toPublicProvider(d));
371
+ }
372
+
373
+ public async getProvider(id: string): Promise<IDnsProviderPublic | null> {
374
+ const doc = await DnsProviderDoc.findById(id);
375
+ return doc ? this.toPublicProvider(doc) : null;
376
+ }
377
+
378
+ public async createProvider(args: {
379
+ name: string;
380
+ type: TDnsProviderType;
381
+ credentials: TDnsProviderCredentials;
382
+ createdBy: string;
383
+ }): Promise<string> {
384
+ const now = Date.now();
385
+ const doc = new DnsProviderDoc();
386
+ doc.id = plugins.uuid.v4();
387
+ doc.name = args.name;
388
+ doc.type = args.type;
389
+ doc.credentials = args.credentials;
390
+ doc.status = 'untested';
391
+ doc.createdAt = now;
392
+ doc.updatedAt = now;
393
+ doc.createdBy = args.createdBy;
394
+ await doc.save();
395
+ return doc.id;
396
+ }
397
+
398
+ public async updateProvider(
399
+ id: string,
400
+ args: { name?: string; credentials?: TDnsProviderCredentials },
401
+ ): Promise<boolean> {
402
+ const doc = await DnsProviderDoc.findById(id);
403
+ if (!doc) return false;
404
+ if (args.name !== undefined) doc.name = args.name;
405
+ if (args.credentials !== undefined) {
406
+ doc.credentials = args.credentials;
407
+ doc.status = 'untested';
408
+ doc.lastError = undefined;
409
+ // Invalidate cached client so the next use re-instantiates with the new credentials.
410
+ this.providerClients.delete(id);
411
+ }
412
+ doc.updatedAt = Date.now();
413
+ await doc.save();
414
+ return true;
415
+ }
416
+
417
+ public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> {
418
+ const doc = await DnsProviderDoc.findById(id);
419
+ if (!doc) return { success: false, message: 'Provider not found' };
420
+ const linkedDomains = await DomainDoc.findByProviderId(id);
421
+ if (linkedDomains.length > 0 && !force) {
422
+ return {
423
+ success: false,
424
+ message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`,
425
+ };
426
+ }
427
+ // If forcing, also delete the linked domains and their records.
428
+ if (force) {
429
+ for (const domain of linkedDomains) {
430
+ await this.deleteDomain(domain.id);
431
+ }
432
+ }
433
+ await doc.delete();
434
+ this.providerClients.delete(id);
435
+ return { success: true };
436
+ }
437
+
438
+ public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> {
439
+ const doc = await DnsProviderDoc.findById(id);
440
+ if (!doc) {
441
+ return { ok: false, error: 'Provider not found', testedAt: Date.now() };
442
+ }
443
+ const client = createDnsProvider(doc.type, doc.credentials);
444
+ const result = await client.testConnection();
445
+ doc.status = result.ok ? 'ok' : 'error';
446
+ doc.lastTestedAt = Date.now();
447
+ doc.lastError = result.ok ? undefined : result.error;
448
+ await doc.save();
449
+ if (result.ok) {
450
+ this.providerClients.set(id, client); // cache the working client
451
+ }
452
+ return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt };
453
+ }
454
+
455
+ public async listProviderDomains(providerId: string): Promise<IProviderDomainListing[]> {
456
+ const client = await this.getProviderClientById(providerId);
457
+ if (!client) {
458
+ throw new Error('Provider not found');
459
+ }
460
+ return await client.listDomains();
461
+ }
462
+
463
+ // ==========================================================================
464
+ // Domain CRUD (used by DomainHandler)
465
+ // ==========================================================================
466
+
467
+ public async listDomains(): Promise<DomainDoc[]> {
468
+ return await DomainDoc.findAll();
469
+ }
470
+
471
+ public async getDomain(id: string): Promise<DomainDoc | null> {
472
+ return await DomainDoc.findById(id);
473
+ }
474
+
475
+ /**
476
+ * Create a manual (authoritative) domain. dcrouter will serve DNS records
477
+ * for this domain via the embedded smartdns.DnsServer.
478
+ */
479
+ public async createManualDomain(args: {
480
+ name: string;
481
+ description?: string;
482
+ createdBy: string;
483
+ }): Promise<string> {
484
+ const now = Date.now();
485
+ const doc = new DomainDoc();
486
+ doc.id = plugins.uuid.v4();
487
+ doc.name = args.name.toLowerCase();
488
+ doc.source = 'manual';
489
+ doc.authoritative = true;
490
+ doc.description = args.description;
491
+ doc.createdAt = now;
492
+ doc.updatedAt = now;
493
+ doc.createdBy = args.createdBy;
494
+ await doc.save();
495
+ return doc.id;
496
+ }
497
+
498
+ /**
499
+ * Import one or more domains from a provider, pulling all of their DNS
500
+ * records into local DnsRecordDocs.
501
+ */
502
+ public async importDomainsFromProvider(args: {
503
+ providerId: string;
504
+ domainNames: string[];
505
+ createdBy: string;
506
+ }): Promise<string[]> {
507
+ const provider = await DnsProviderDoc.findById(args.providerId);
508
+ if (!provider) {
509
+ throw new Error('Provider not found');
510
+ }
511
+ const client = await this.getProviderClientById(args.providerId);
512
+ if (!client) {
513
+ throw new Error('Failed to instantiate provider client');
514
+ }
515
+ const allProviderDomains = await client.listDomains();
516
+ const importedIds: string[] = [];
517
+ const now = Date.now();
518
+
519
+ for (const wantedName of args.domainNames) {
520
+ const lower = wantedName.toLowerCase();
521
+ const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower);
522
+ if (!listing) {
523
+ logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`);
524
+ continue;
525
+ }
526
+ // Skip if already imported
527
+ const existing = await DomainDoc.findByName(lower);
528
+ if (existing) {
529
+ logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`);
530
+ continue;
531
+ }
532
+
533
+ const domain = new DomainDoc();
534
+ domain.id = plugins.uuid.v4();
535
+ domain.name = lower;
536
+ domain.source = 'provider';
537
+ domain.providerId = args.providerId;
538
+ domain.authoritative = false;
539
+ domain.nameservers = listing.nameservers;
540
+ domain.externalZoneId = listing.externalId;
541
+ domain.lastSyncedAt = now;
542
+ domain.createdAt = now;
543
+ domain.updatedAt = now;
544
+ domain.createdBy = args.createdBy;
545
+ await domain.save();
546
+ importedIds.push(domain.id);
547
+
548
+ // Pull records for the imported domain
549
+ try {
550
+ const providerRecords = await client.listRecords(lower);
551
+ for (const pr of providerRecords) {
552
+ await this.createSyncedRecord(domain.id, pr, args.createdBy);
553
+ }
554
+ logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`);
555
+ } catch (err: unknown) {
556
+ logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`);
557
+ }
558
+ }
559
+ return importedIds;
560
+ }
561
+
562
+ public async updateDomain(id: string, args: { description?: string }): Promise<boolean> {
563
+ const doc = await DomainDoc.findById(id);
564
+ if (!doc) return false;
565
+ if (args.description !== undefined) doc.description = args.description;
566
+ doc.updatedAt = Date.now();
567
+ await doc.save();
568
+ return true;
569
+ }
570
+
571
+ /**
572
+ * Delete a domain and all of its DNS records. For provider domains, only
573
+ * removes the local mirror — does NOT touch the provider.
574
+ * For manual domains, also unregisters records from the embedded DnsServer.
575
+ *
576
+ * Note: smartdns has no public unregister-by-name API in the version pinned
577
+ * here, so manual record deletes only take effect after a restart. The DB
578
+ * is the source of truth and the next start will not register the deleted
579
+ * record.
580
+ */
581
+ public async deleteDomain(id: string): Promise<boolean> {
582
+ const doc = await DomainDoc.findById(id);
583
+ if (!doc) return false;
584
+ const records = await DnsRecordDoc.findByDomainId(id);
585
+ for (const r of records) {
586
+ await r.delete();
587
+ }
588
+ await doc.delete();
589
+ return true;
590
+ }
591
+
592
+ /**
593
+ * Force-resync a provider-managed domain: re-pull all records from the
594
+ * provider API, replacing the cached DnsRecordDocs.
595
+ */
596
+ public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> {
597
+ const doc = await DomainDoc.findById(id);
598
+ if (!doc) return { success: false, message: 'Domain not found' };
599
+ if (doc.source !== 'provider' || !doc.providerId) {
600
+ return { success: false, message: 'Domain is not provider-managed' };
601
+ }
602
+ const client = await this.getProviderClientById(doc.providerId);
603
+ if (!client) {
604
+ return { success: false, message: 'Provider client unavailable' };
605
+ }
606
+ const providerRecords = await client.listRecords(doc.name);
607
+
608
+ // Drop existing records and replace
609
+ const existing = await DnsRecordDoc.findByDomainId(id);
610
+ for (const r of existing) {
611
+ await r.delete();
612
+ }
613
+ for (const pr of providerRecords) {
614
+ await this.createSyncedRecord(id, pr, doc.createdBy);
615
+ }
616
+ doc.lastSyncedAt = Date.now();
617
+ doc.updatedAt = doc.lastSyncedAt;
618
+ await doc.save();
619
+ return { success: true, recordCount: providerRecords.length };
620
+ }
621
+
622
+ // ==========================================================================
623
+ // Record CRUD (used by DnsRecordHandler)
624
+ // ==========================================================================
625
+
626
+ public async listRecordsForDomain(domainId: string): Promise<DnsRecordDoc[]> {
627
+ return await DnsRecordDoc.findByDomainId(domainId);
628
+ }
629
+
630
+ public async getRecord(id: string): Promise<DnsRecordDoc | null> {
631
+ return await DnsRecordDoc.findById(id);
632
+ }
633
+
634
+ public async createRecord(args: {
635
+ domainId: string;
636
+ name: string;
637
+ type: TDnsRecordType;
638
+ value: string;
639
+ ttl?: number;
640
+ proxied?: boolean;
641
+ createdBy: string;
642
+ }): Promise<{ success: boolean; id?: string; message?: string }> {
643
+ const domain = await DomainDoc.findById(args.domainId);
644
+ if (!domain) return { success: false, message: 'Domain not found' };
645
+
646
+ const now = Date.now();
647
+ const doc = new DnsRecordDoc();
648
+ doc.id = plugins.uuid.v4();
649
+ doc.domainId = args.domainId;
650
+ doc.name = args.name.toLowerCase();
651
+ doc.type = args.type;
652
+ doc.value = args.value;
653
+ doc.ttl = args.ttl ?? 300;
654
+ if (args.proxied !== undefined) doc.proxied = args.proxied;
655
+ doc.source = 'manual';
656
+ doc.createdAt = now;
657
+ doc.updatedAt = now;
658
+ doc.createdBy = args.createdBy;
659
+
660
+ if (domain.source === 'provider') {
661
+ // Push to provider first; only persist locally on success
662
+ if (!domain.providerId) {
663
+ return { success: false, message: 'Provider domain has no providerId' };
664
+ }
665
+ const client = await this.getProviderClientById(domain.providerId);
666
+ if (!client) return { success: false, message: 'Provider client unavailable' };
667
+ try {
668
+ const created = await client.createRecord(domain.name, {
669
+ name: doc.name,
670
+ type: doc.type,
671
+ value: doc.value,
672
+ ttl: doc.ttl,
673
+ proxied: doc.proxied,
674
+ });
675
+ doc.providerRecordId = created.providerRecordId;
676
+ doc.source = 'synced';
677
+ } catch (err: unknown) {
678
+ return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
679
+ }
680
+ } else {
681
+ // Manual / authoritative — register with embedded DnsServer immediately
682
+ this.registerRecordWithDnsServer(doc);
683
+ }
684
+
685
+ await doc.save();
686
+ return { success: true, id: doc.id };
687
+ }
688
+
689
+ public async updateRecord(args: {
690
+ id: string;
691
+ name?: string;
692
+ value?: string;
693
+ ttl?: number;
694
+ proxied?: boolean;
695
+ }): Promise<{ success: boolean; message?: string }> {
696
+ const doc = await DnsRecordDoc.findById(args.id);
697
+ if (!doc) return { success: false, message: 'Record not found' };
698
+ const domain = await DomainDoc.findById(doc.domainId);
699
+ if (!domain) return { success: false, message: 'Parent domain not found' };
700
+
701
+ if (args.name !== undefined) doc.name = args.name.toLowerCase();
702
+ if (args.value !== undefined) doc.value = args.value;
703
+ if (args.ttl !== undefined) doc.ttl = args.ttl;
704
+ if (args.proxied !== undefined) doc.proxied = args.proxied;
705
+ doc.updatedAt = Date.now();
706
+
707
+ if (domain.source === 'provider') {
708
+ if (!domain.providerId || !doc.providerRecordId) {
709
+ return { success: false, message: 'Provider record metadata missing' };
710
+ }
711
+ const client = await this.getProviderClientById(domain.providerId);
712
+ if (!client) return { success: false, message: 'Provider client unavailable' };
713
+ try {
714
+ await client.updateRecord(domain.name, doc.providerRecordId, {
715
+ name: doc.name,
716
+ type: doc.type,
717
+ value: doc.value,
718
+ ttl: doc.ttl,
719
+ proxied: doc.proxied,
720
+ });
721
+ } catch (err: unknown) {
722
+ return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
723
+ }
724
+ } else {
725
+ // Re-register the manual record so the new closure picks up the updated fields
726
+ this.registerRecordWithDnsServer(doc);
727
+ }
728
+
729
+ await doc.save();
730
+ return { success: true };
731
+ }
732
+
733
+ public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> {
734
+ const doc = await DnsRecordDoc.findById(id);
735
+ if (!doc) return { success: false, message: 'Record not found' };
736
+ const domain = await DomainDoc.findById(doc.domainId);
737
+ if (!domain) return { success: false, message: 'Parent domain not found' };
738
+
739
+ if (domain.source === 'provider') {
740
+ if (domain.providerId && doc.providerRecordId) {
741
+ const client = await this.getProviderClientById(domain.providerId);
742
+ if (client) {
743
+ try {
744
+ await client.deleteRecord(domain.name, doc.providerRecordId);
745
+ } catch (err: unknown) {
746
+ return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
747
+ }
748
+ }
749
+ }
750
+ }
751
+ // For manual records: smartdns has no unregister API in the pinned version,
752
+ // so the record stays served until the next restart. The DB delete still
753
+ // takes effect — on restart, the record will not be re-registered.
754
+
755
+ await doc.delete();
756
+ return { success: true };
757
+ }
758
+
759
+ // ==========================================================================
760
+ // Internal helpers
761
+ // ==========================================================================
762
+
763
+ private async createSyncedRecord(
764
+ domainId: string,
765
+ pr: IProviderRecord,
766
+ createdBy: string,
767
+ ): Promise<void> {
768
+ const now = Date.now();
769
+ const doc = new DnsRecordDoc();
770
+ doc.id = plugins.uuid.v4();
771
+ doc.domainId = domainId;
772
+ doc.name = pr.name.toLowerCase();
773
+ doc.type = pr.type;
774
+ doc.value = pr.value;
775
+ doc.ttl = pr.ttl;
776
+ if (pr.proxied !== undefined) doc.proxied = pr.proxied;
777
+ doc.source = 'synced';
778
+ doc.providerRecordId = pr.providerRecordId;
779
+ doc.createdAt = now;
780
+ doc.updatedAt = now;
781
+ doc.createdBy = createdBy;
782
+ await doc.save();
783
+ }
784
+
785
+ /**
786
+ * Convert a DnsProviderDoc to its public, secret-stripped representation
787
+ * for the OpsServer API.
788
+ */
789
+ public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic {
790
+ return {
791
+ id: doc.id,
792
+ name: doc.name,
793
+ type: doc.type,
794
+ status: doc.status,
795
+ lastTestedAt: doc.lastTestedAt,
796
+ lastError: doc.lastError,
797
+ createdAt: doc.createdAt,
798
+ updatedAt: doc.updatedAt,
799
+ createdBy: doc.createdBy,
800
+ hasCredentials: !!doc.credentials,
801
+ };
802
+ }
803
+
804
+ /**
805
+ * Convert a DomainDoc to its plain interface representation.
806
+ */
807
+ public toPublicDomain(doc: DomainDoc): {
808
+ id: string;
809
+ name: string;
810
+ source: 'manual' | 'provider';
811
+ providerId?: string;
812
+ authoritative: boolean;
813
+ nameservers?: string[];
814
+ externalZoneId?: string;
815
+ lastSyncedAt?: number;
816
+ description?: string;
817
+ createdAt: number;
818
+ updatedAt: number;
819
+ createdBy: string;
820
+ } {
821
+ return {
822
+ id: doc.id,
823
+ name: doc.name,
824
+ source: doc.source,
825
+ providerId: doc.providerId,
826
+ authoritative: doc.authoritative,
827
+ nameservers: doc.nameservers,
828
+ externalZoneId: doc.externalZoneId,
829
+ lastSyncedAt: doc.lastSyncedAt,
830
+ description: doc.description,
831
+ createdAt: doc.createdAt,
832
+ updatedAt: doc.updatedAt,
833
+ createdBy: doc.createdBy,
834
+ };
835
+ }
836
+
837
+ /**
838
+ * Convert a DnsRecordDoc to its plain interface representation.
839
+ */
840
+ public toPublicRecord(doc: DnsRecordDoc): {
841
+ id: string;
842
+ domainId: string;
843
+ name: string;
844
+ type: TDnsRecordType;
845
+ value: string;
846
+ ttl: number;
847
+ proxied?: boolean;
848
+ source: TDnsRecordSource;
849
+ providerRecordId?: string;
850
+ createdAt: number;
851
+ updatedAt: number;
852
+ createdBy: string;
853
+ } {
854
+ return {
855
+ id: doc.id,
856
+ domainId: doc.domainId,
857
+ name: doc.name,
858
+ type: doc.type,
859
+ value: doc.value,
860
+ ttl: doc.ttl,
861
+ proxied: doc.proxied,
862
+ source: doc.source,
863
+ providerRecordId: doc.providerRecordId,
864
+ createdAt: doc.createdAt,
865
+ updatedAt: doc.updatedAt,
866
+ createdBy: doc.createdBy,
867
+ };
868
+ }
869
+ }