@serve.zone/dcrouter 13.18.0 → 13.19.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 (35) hide show
  1. package/dist_serve/bundle.js +532 -531
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +2 -0
  4. package/dist_ts/classes.dcrouter.js +50 -39
  5. package/dist_ts/config/classes.route-config-manager.d.ts +14 -5
  6. package/dist_ts/config/classes.route-config-manager.js +121 -44
  7. package/dist_ts/db/documents/classes.route.doc.d.ts +2 -0
  8. package/dist_ts/db/documents/classes.route.doc.js +11 -2
  9. package/dist_ts/email/classes.email-domain.manager.js +9 -28
  10. package/dist_ts/email/email-dns-records.d.ts +14 -0
  11. package/dist_ts/email/email-dns-records.js +34 -0
  12. package/dist_ts/email/index.d.ts +1 -0
  13. package/dist_ts/email/index.js +2 -1
  14. package/dist_ts/opsserver/handlers/route-management.handler.js +5 -7
  15. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  16. package/dist_ts_migrations/index.js +25 -1
  17. package/dist_ts_web/00_commitinfo_data.js +1 -1
  18. package/dist_ts_web/appstate.js +13 -4
  19. package/dist_ts_web/elements/network/ops-view-routes.d.ts +2 -0
  20. package/dist_ts_web/elements/network/ops-view-routes.js +124 -36
  21. package/package.json +2 -3
  22. package/readme.md +190 -1543
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/classes.dcrouter.ts +61 -47
  25. package/ts/config/classes.route-config-manager.ts +148 -50
  26. package/ts/db/documents/classes.route.doc.ts +7 -0
  27. package/ts/email/classes.email-domain.manager.ts +8 -28
  28. package/ts/email/email-dns-records.ts +53 -0
  29. package/ts/email/index.ts +1 -0
  30. package/ts/opsserver/handlers/route-management.handler.ts +4 -6
  31. package/ts_apiclient/readme.md +69 -195
  32. package/ts_web/00_commitinfo_data.ts +1 -1
  33. package/ts_web/appstate.ts +16 -4
  34. package/ts_web/elements/network/ops-view-routes.ts +136 -44
  35. package/ts_web/readme.md +41 -242
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.18.0',
6
+ version: '13.19.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -30,7 +30,8 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
30
30
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
31
31
  import { DnsManager } from './dns/manager.dns.js';
32
32
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
33
- import { EmailDomainManager, SmartMtaStorageManager } from './email/index.js';
33
+ import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
34
+ import type { IRoute } from '../ts_interfaces/data/route-management.js';
34
35
 
35
36
  export interface IDcRouterOptions {
36
37
  /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -314,7 +315,8 @@ export class DcRouter {
314
315
  // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
315
316
  private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
316
317
  private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
317
- // Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
318
+ private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
319
+ // Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
318
320
  private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
319
321
 
320
322
  // Environment access
@@ -588,13 +590,15 @@ export class DcRouter {
588
590
  this.tunnelManager.syncAllowedEdges();
589
591
  }
590
592
  },
591
- () => this.runtimeDnsRoutes,
593
+ undefined,
594
+ (storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
592
595
  );
593
596
  this.apiTokenManager = new ApiTokenManager();
594
597
  await this.apiTokenManager.initialize();
595
598
  await this.routeConfigManager.initialize(
596
599
  this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
597
600
  this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
601
+ this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
598
602
  );
599
603
  await this.targetProfileManager.normalizeAllRouteRefs();
600
604
 
@@ -912,10 +916,12 @@ export class DcRouter {
912
916
  logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
913
917
  }
914
918
 
919
+ this.seedDnsRoutes = [];
915
920
  this.runtimeDnsRoutes = [];
916
921
  if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
917
- this.runtimeDnsRoutes = this.generateDnsRoutes();
918
- logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
922
+ this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
923
+ this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
924
+ logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
919
925
  }
920
926
 
921
927
  // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
@@ -1338,19 +1344,20 @@ export class DcRouter {
1338
1344
  /**
1339
1345
  * Generate SmartProxy routes for DNS configuration
1340
1346
  */
1341
- private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] {
1347
+ private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] {
1342
1348
  if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
1343
1349
  return [];
1344
1350
  }
1345
-
1351
+
1352
+ const includeSocketHandler = options?.includeSocketHandler !== false;
1346
1353
  const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
1347
-
1354
+
1348
1355
  // Create routes for DNS-over-HTTPS paths
1349
1356
  const dohPaths = ['/dns-query', '/resolve'];
1350
-
1357
+
1351
1358
  // Use the first nameserver domain for DoH routes
1352
1359
  const primaryNameserver = this.options.dnsNsDomains[0];
1353
-
1360
+
1354
1361
  for (const path of dohPaths) {
1355
1362
  const dohRoute: plugins.smartproxy.IRouteConfig = {
1356
1363
  name: `dns-over-https-${path.replace('/', '')}`,
@@ -1359,18 +1366,42 @@ export class DcRouter {
1359
1366
  domains: [primaryNameserver],
1360
1367
  path: path
1361
1368
  },
1362
- action: {
1363
- type: 'socket-handler' as any,
1364
- socketHandler: this.createDnsSocketHandler()
1365
- } as any
1369
+ action: includeSocketHandler
1370
+ ? {
1371
+ type: 'socket-handler' as any,
1372
+ socketHandler: this.createDnsSocketHandler()
1373
+ } as any
1374
+ : {
1375
+ type: 'socket-handler' as any,
1376
+ } as any
1366
1377
  };
1367
-
1378
+
1368
1379
  dnsRoutes.push(dohRoute);
1369
1380
  }
1370
-
1381
+
1371
1382
  return dnsRoutes;
1372
1383
  }
1373
1384
 
1385
+ private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
1386
+ const routeName = storedRoute.route.name || '';
1387
+ const isDohRoute = storedRoute.origin === 'dns'
1388
+ && storedRoute.route.action?.type === 'socket-handler'
1389
+ && routeName.startsWith('dns-over-https-');
1390
+
1391
+ if (!isDohRoute) {
1392
+ return undefined;
1393
+ }
1394
+
1395
+ return {
1396
+ ...storedRoute.route,
1397
+ action: {
1398
+ ...storedRoute.route.action,
1399
+ type: 'socket-handler' as any,
1400
+ socketHandler: this.createDnsSocketHandler(),
1401
+ } as any,
1402
+ };
1403
+ }
1404
+
1374
1405
  /**
1375
1406
  * Check if a domain matches a pattern (including wildcard support)
1376
1407
  * @param domain The domain to check
@@ -1939,37 +1970,20 @@ export class DcRouter {
1939
1970
  for (const domainConfig of internalDnsDomains) {
1940
1971
  const domain = domainConfig.domain;
1941
1972
  const ttl = domainConfig.dns?.internal?.ttl || 3600;
1942
- const mxPriority = domainConfig.dns?.internal?.mxPriority || 10;
1943
-
1944
- // MX record - points to the domain itself for email handling
1945
- records.push({
1946
- name: domain,
1947
- type: 'MX',
1948
- value: `${mxPriority} ${domain}`,
1949
- ttl
1950
- });
1951
-
1952
- // SPF record - using sensible defaults
1953
- const spfRecord = 'v=spf1 a mx ~all';
1954
- records.push({
1955
- name: domain,
1956
- type: 'TXT',
1957
- value: spfRecord,
1958
- ttl
1959
- });
1960
-
1961
- // DMARC record - using sensible defaults
1962
- const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
1963
- const dmarcEmail = `dmarc@${domain}`;
1964
- records.push({
1965
- name: `_dmarc.${domain}`,
1966
- type: 'TXT',
1967
- value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
1968
- ttl
1969
- });
1970
-
1971
- // Note: DKIM records will be generated later when DKIM keys are available
1972
- // They require the DKIMCreator which is part of the email server
1973
+ const requiredRecords = buildEmailDnsRecords({
1974
+ domain,
1975
+ hostname: this.options.emailConfig.hostname,
1976
+ mxPriority: domainConfig.dns?.internal?.mxPriority,
1977
+ }).filter((record) => !record.name.includes('._domainkey.'));
1978
+
1979
+ for (const record of requiredRecords) {
1980
+ records.push({
1981
+ name: record.name,
1982
+ type: record.type,
1983
+ value: record.value,
1984
+ ttl,
1985
+ });
1986
+ }
1973
1987
  }
1974
1988
 
1975
1989
  logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
@@ -14,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js';
14
14
  /** An IP allow entry: plain IP/CIDR or domain-scoped. */
15
15
  export type TIpAllowEntry = string | { ip: string; domains: string[] };
16
16
 
17
+ export interface IRouteMutationResult {
18
+ success: boolean;
19
+ message?: string;
20
+ }
21
+
17
22
  /**
18
23
  * Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
19
24
  * never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
@@ -56,6 +61,7 @@ export class RouteConfigManager {
56
61
  private referenceResolver?: ReferenceResolver,
57
62
  private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
58
63
  private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
64
+ private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
59
65
  ) {}
60
66
 
61
67
  /** Expose routes map for reference resolution lookups. */
@@ -63,6 +69,10 @@ export class RouteConfigManager {
63
69
  return this.routes;
64
70
  }
65
71
 
72
+ public getRoute(id: string): IRoute | undefined {
73
+ return this.routes.get(id);
74
+ }
75
+
66
76
  /**
67
77
  * Load persisted routes, seed serializable config/email/dns routes,
68
78
  * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
@@ -94,6 +104,7 @@ export class RouteConfigManager {
94
104
  id: route.id,
95
105
  enabled: route.enabled,
96
106
  origin: route.origin,
107
+ systemKey: route.systemKey,
97
108
  createdAt: route.createdAt,
98
109
  updatedAt: route.updatedAt,
99
110
  metadata: route.metadata,
@@ -122,11 +133,11 @@ export class RouteConfigManager {
122
133
  }
123
134
 
124
135
  // Resolve references if metadata has refs and resolver is available
125
- let resolvedMetadata = metadata;
126
- if (metadata && this.referenceResolver) {
127
- const resolved = this.referenceResolver.resolveRoute(route, metadata);
136
+ let resolvedMetadata = this.normalizeRouteMetadata(metadata);
137
+ if (resolvedMetadata && this.referenceResolver) {
138
+ const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
128
139
  route = resolved.route;
129
- resolvedMetadata = resolved.metadata;
140
+ resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
130
141
  }
131
142
 
132
143
  const stored: IRoute = {
@@ -153,9 +164,21 @@ export class RouteConfigManager {
153
164
  enabled?: boolean;
154
165
  metadata?: Partial<IRouteMetadata>;
155
166
  },
156
- ): Promise<boolean> {
167
+ ): Promise<IRouteMutationResult> {
157
168
  const stored = this.routes.get(id);
158
- if (!stored) return false;
169
+ if (!stored) {
170
+ return { success: false, message: 'Route not found' };
171
+ }
172
+
173
+ const isToggleOnlyPatch = patch.enabled !== undefined
174
+ && patch.route === undefined
175
+ && patch.metadata === undefined;
176
+ if (stored.origin !== 'api' && !isToggleOnlyPatch) {
177
+ return {
178
+ success: false,
179
+ message: 'System routes are managed by the system and can only be toggled',
180
+ };
181
+ }
159
182
 
160
183
  if (patch.route) {
161
184
  const mergedAction = patch.route.action
@@ -175,33 +198,46 @@ export class RouteConfigManager {
175
198
  stored.enabled = patch.enabled;
176
199
  }
177
200
  if (patch.metadata !== undefined) {
178
- stored.metadata = { ...stored.metadata, ...patch.metadata };
201
+ stored.metadata = this.normalizeRouteMetadata({
202
+ ...stored.metadata,
203
+ ...patch.metadata,
204
+ });
179
205
  }
180
206
 
181
207
  // Re-resolve if metadata refs exist and resolver is available
182
208
  if (stored.metadata && this.referenceResolver) {
183
209
  const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
184
210
  stored.route = resolved.route;
185
- stored.metadata = resolved.metadata;
211
+ stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
186
212
  }
187
213
 
188
214
  stored.updatedAt = Date.now();
189
215
 
190
216
  await this.persistRoute(stored);
191
217
  await this.applyRoutes();
192
- return true;
218
+ return { success: true };
193
219
  }
194
220
 
195
- public async deleteRoute(id: string): Promise<boolean> {
196
- if (!this.routes.has(id)) return false;
221
+ public async deleteRoute(id: string): Promise<IRouteMutationResult> {
222
+ const stored = this.routes.get(id);
223
+ if (!stored) {
224
+ return { success: false, message: 'Route not found' };
225
+ }
226
+ if (stored.origin !== 'api') {
227
+ return {
228
+ success: false,
229
+ message: 'System routes are managed by the system and cannot be deleted',
230
+ };
231
+ }
232
+
197
233
  this.routes.delete(id);
198
234
  const doc = await RouteDoc.findById(id);
199
235
  if (doc) await doc.delete();
200
236
  await this.applyRoutes();
201
- return true;
237
+ return { success: true };
202
238
  }
203
239
 
204
- public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
240
+ public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
205
241
  return this.updateRoute(id, { enabled });
206
242
  }
207
243
 
@@ -217,29 +253,28 @@ export class RouteConfigManager {
217
253
  seedRoutes: IDcRouterRouteConfig[],
218
254
  origin: 'config' | 'email' | 'dns',
219
255
  ): Promise<void> {
220
- if (seedRoutes.length === 0) return;
221
-
256
+ const seedSystemKeys = new Set<string>();
222
257
  const seedNames = new Set<string>();
223
258
  let seeded = 0;
224
259
  let updated = 0;
225
260
 
226
261
  for (const route of seedRoutes) {
227
262
  const name = route.name || '';
228
- seedNames.add(name);
229
-
230
- // Check if a route with this name+origin already exists in memory
231
- let existingId: string | undefined;
232
- for (const [id, r] of this.routes) {
233
- if (r.origin === origin && r.route.name === name) {
234
- existingId = id;
235
- break;
236
- }
263
+ if (name) {
264
+ seedNames.add(name);
265
+ }
266
+ const systemKey = this.buildSystemRouteKey(origin, route);
267
+ if (systemKey) {
268
+ seedSystemKeys.add(systemKey);
237
269
  }
238
270
 
271
+ const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
272
+
239
273
  if (existingId) {
240
274
  // Update route config but preserve enabled state
241
275
  const existing = this.routes.get(existingId)!;
242
276
  existing.route = route;
277
+ existing.systemKey = systemKey;
243
278
  existing.updatedAt = Date.now();
244
279
  await this.persistRoute(existing);
245
280
  updated++;
@@ -255,6 +290,7 @@ export class RouteConfigManager {
255
290
  updatedAt: now,
256
291
  createdBy: 'system',
257
292
  origin,
293
+ systemKey,
258
294
  };
259
295
  this.routes.set(id, newRoute);
260
296
  await this.persistRoute(newRoute);
@@ -265,7 +301,12 @@ export class RouteConfigManager {
265
301
  // Delete stale routes: same origin but name not in current seed set
266
302
  const staleIds: string[] = [];
267
303
  for (const [id, r] of this.routes) {
268
- if (r.origin === origin && !seedNames.has(r.route.name || '')) {
304
+ if (r.origin !== origin) continue;
305
+
306
+ const routeName = r.route.name || '';
307
+ const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
308
+ const matchesSeedName = routeName ? seedNames.has(routeName) : false;
309
+ if (!matchesSeedSystemKey && !matchesSeedName) {
269
310
  staleIds.push(id);
270
311
  }
271
312
  }
@@ -284,9 +325,39 @@ export class RouteConfigManager {
284
325
  // Private: persistence
285
326
  // =========================================================================
286
327
 
328
+ private buildSystemRouteKey(
329
+ origin: 'config' | 'email' | 'dns',
330
+ route: IDcRouterRouteConfig,
331
+ ): string | undefined {
332
+ const name = route.name?.trim();
333
+ if (!name) return undefined;
334
+ return `${origin}:${name}`;
335
+ }
336
+
337
+ private findExistingSeedRouteId(
338
+ origin: 'config' | 'email' | 'dns',
339
+ route: IDcRouterRouteConfig,
340
+ systemKey?: string,
341
+ ): string | undefined {
342
+ const routeName = route.name || '';
343
+
344
+ for (const [id, storedRoute] of this.routes) {
345
+ if (storedRoute.origin !== origin) continue;
346
+
347
+ if (systemKey && storedRoute.systemKey === systemKey) {
348
+ return id;
349
+ }
350
+
351
+ if (storedRoute.route.name === routeName) {
352
+ return id;
353
+ }
354
+ }
355
+
356
+ return undefined;
357
+ }
358
+
287
359
  private async loadRoutes(): Promise<void> {
288
360
  const docs = await RouteDoc.findAll();
289
- let prunedRuntimeRoutes = 0;
290
361
 
291
362
  for (const doc of docs) {
292
363
  if (!doc.id) continue;
@@ -299,27 +370,15 @@ export class RouteConfigManager {
299
370
  updatedAt: doc.updatedAt,
300
371
  createdBy: doc.createdBy,
301
372
  origin: doc.origin || 'api',
302
- metadata: doc.metadata,
373
+ systemKey: doc.systemKey,
374
+ metadata: this.normalizeRouteMetadata(doc.metadata),
303
375
  };
304
376
 
305
- if (this.isPersistedRuntimeRoute(storedRoute)) {
306
- await doc.delete();
307
- prunedRuntimeRoutes++;
308
- logger.log(
309
- 'warn',
310
- `Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
311
- );
312
- continue;
313
- }
314
-
315
377
  this.routes.set(doc.id, storedRoute);
316
378
  }
317
379
  if (this.routes.size > 0) {
318
380
  logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
319
381
  }
320
- if (prunedRuntimeRoutes > 0) {
321
- logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
322
- }
323
382
  }
324
383
 
325
384
  private async persistRoute(stored: IRoute): Promise<void> {
@@ -330,6 +389,7 @@ export class RouteConfigManager {
330
389
  existingDoc.updatedAt = stored.updatedAt;
331
390
  existingDoc.createdBy = stored.createdBy;
332
391
  existingDoc.origin = stored.origin;
392
+ existingDoc.systemKey = stored.systemKey;
333
393
  existingDoc.metadata = stored.metadata;
334
394
  await existingDoc.save();
335
395
  } else {
@@ -341,11 +401,52 @@ export class RouteConfigManager {
341
401
  doc.updatedAt = stored.updatedAt;
342
402
  doc.createdBy = stored.createdBy;
343
403
  doc.origin = stored.origin;
404
+ doc.systemKey = stored.systemKey;
344
405
  doc.metadata = stored.metadata;
345
406
  await doc.save();
346
407
  }
347
408
  }
348
409
 
410
+ private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
411
+ if (!metadata) {
412
+ return undefined;
413
+ }
414
+
415
+ const normalizeString = (value?: string): string | undefined => {
416
+ if (typeof value !== 'string') {
417
+ return undefined;
418
+ }
419
+ const trimmed = value.trim();
420
+ return trimmed.length > 0 ? trimmed : undefined;
421
+ };
422
+
423
+ const normalized: IRouteMetadata = {
424
+ sourceProfileRef: normalizeString(metadata.sourceProfileRef),
425
+ networkTargetRef: normalizeString(metadata.networkTargetRef),
426
+ sourceProfileName: normalizeString(metadata.sourceProfileName),
427
+ networkTargetName: normalizeString(metadata.networkTargetName),
428
+ lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
429
+ ? metadata.lastResolvedAt
430
+ : undefined,
431
+ };
432
+
433
+ if (!normalized.sourceProfileRef) {
434
+ normalized.sourceProfileName = undefined;
435
+ }
436
+ if (!normalized.networkTargetRef) {
437
+ normalized.networkTargetName = undefined;
438
+ }
439
+ if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
440
+ normalized.lastResolvedAt = undefined;
441
+ }
442
+
443
+ if (Object.values(normalized).every((value) => value === undefined)) {
444
+ return undefined;
445
+ }
446
+
447
+ return normalized;
448
+ }
449
+
349
450
  // =========================================================================
350
451
  // Private: warnings
351
452
  // =========================================================================
@@ -388,7 +489,7 @@ export class RouteConfigManager {
388
489
 
389
490
  const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
390
491
  stored.route = resolved.route;
391
- stored.metadata = resolved.metadata;
492
+ stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
392
493
  stored.updatedAt = Date.now();
393
494
  await this.persistRoute(stored);
394
495
  }
@@ -411,7 +512,7 @@ export class RouteConfigManager {
411
512
  // Add all enabled routes with HTTP/3 and VPN augmentation
412
513
  for (const route of this.routes.values()) {
413
514
  if (route.enabled) {
414
- enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
515
+ enabledRoutes.push(this.prepareStoredRouteForApply(route));
415
516
  }
416
517
  }
417
518
 
@@ -431,6 +532,11 @@ export class RouteConfigManager {
431
532
  });
432
533
  }
433
534
 
535
+ private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
536
+ const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
537
+ return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
538
+ }
539
+
434
540
  private prepareRouteForApply(
435
541
  route: plugins.smartproxy.IRouteConfig,
436
542
  routeId?: string,
@@ -465,12 +571,4 @@ export class RouteConfigManager {
465
571
  },
466
572
  };
467
573
  }
468
-
469
- private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
470
- const routeName = storedRoute.route.name || '';
471
- const actionType = storedRoute.route.action?.type;
472
-
473
- return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
474
- || (storedRoute.origin === 'dns' && actionType === 'socket-handler');
475
- }
476
574
  }
@@ -29,6 +29,9 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
29
29
  @plugins.smartdata.svDb()
30
30
  public origin!: 'config' | 'email' | 'dns' | 'api';
31
31
 
32
+ @plugins.smartdata.svDb()
33
+ public systemKey?: string;
34
+
32
35
  @plugins.smartdata.svDb()
33
36
  public metadata?: IRouteMetadata;
34
37
 
@@ -51,4 +54,8 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
51
54
  public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
52
55
  return await RouteDoc.getInstances({ origin });
53
56
  }
57
+
58
+ public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
59
+ return await RouteDoc.getInstance({ systemKey });
60
+ }
54
61
  }
@@ -6,6 +6,7 @@ import { DomainDoc } from '../db/documents/classes.domain.doc.js';
6
6
  import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
7
7
  import type { DnsManager } from '../dns/manager.dns.js';
8
8
  import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
9
+ import { buildEmailDnsRecords } from './email-dns-records.js';
9
10
 
10
11
  /**
11
12
  * EmailDomainManager — orchestrates email domain setup.
@@ -181,34 +182,13 @@ export class EmailDomainManager {
181
182
  }
182
183
  }
183
184
 
184
- const records: IEmailDnsRecord[] = [
185
- {
186
- type: 'MX',
187
- name: domain,
188
- value: `10 ${hostname}`,
189
- status: doc.dnsStatus.mx,
190
- },
191
- {
192
- type: 'TXT',
193
- name: domain,
194
- value: 'v=spf1 a mx ~all',
195
- status: doc.dnsStatus.spf,
196
- },
197
- {
198
- type: 'TXT',
199
- name: `${selector}._domainkey.${domain}`,
200
- value: dkimValue,
201
- status: doc.dnsStatus.dkim,
202
- },
203
- {
204
- type: 'TXT',
205
- name: `_dmarc.${domain}`,
206
- value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
207
- status: doc.dnsStatus.dmarc,
208
- },
209
- ];
210
-
211
- return records;
185
+ return buildEmailDnsRecords({
186
+ domain,
187
+ hostname,
188
+ selector,
189
+ dkimValue,
190
+ statuses: doc.dnsStatus,
191
+ });
212
192
  }
213
193
 
214
194
  // ---------------------------------------------------------------------------
@@ -0,0 +1,53 @@
1
+ import type {
2
+ IEmailDnsRecord,
3
+ TDnsRecordStatus,
4
+ } from '../../ts_interfaces/data/email-domain.js';
5
+
6
+ type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
7
+
8
+ export interface IBuildEmailDnsRecordsOptions {
9
+ domain: string;
10
+ hostname: string;
11
+ selector?: string;
12
+ dkimValue?: string;
13
+ mxPriority?: number;
14
+ dmarcPolicy?: string;
15
+ dmarcRua?: string;
16
+ statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
17
+ }
18
+
19
+ export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
20
+ const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
21
+ const selector = options.selector || 'default';
22
+ const records: IEmailDnsRecord[] = [
23
+ {
24
+ type: 'MX',
25
+ name: options.domain,
26
+ value: `${options.mxPriority ?? 10} ${options.hostname}`,
27
+ status: statusFor('mx'),
28
+ },
29
+ {
30
+ type: 'TXT',
31
+ name: options.domain,
32
+ value: 'v=spf1 a mx ~all',
33
+ status: statusFor('spf'),
34
+ },
35
+ {
36
+ type: 'TXT',
37
+ name: `_dmarc.${options.domain}`,
38
+ value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
39
+ status: statusFor('dmarc'),
40
+ },
41
+ ];
42
+
43
+ if (options.dkimValue) {
44
+ records.splice(2, 0, {
45
+ type: 'TXT',
46
+ name: `${selector}._domainkey.${options.domain}`,
47
+ value: options.dkimValue,
48
+ status: statusFor('dkim'),
49
+ });
50
+ }
51
+
52
+ return records;
53
+ }
package/ts/email/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './classes.email-domain.manager.js';
2
2
  export * from './classes.smartmta-storage-manager.js';
3
+ export * from './email-dns-records.js';