@serve.zone/dcrouter 13.17.3 → 13.17.8

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 (31) hide show
  1. package/dist_serve/bundle.js +128 -128
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +5 -1
  4. package/dist_ts/classes.dcrouter.js +31 -15
  5. package/dist_ts/config/classes.route-config-manager.d.ts +7 -2
  6. package/dist_ts/config/classes.route-config-manager.js +64 -38
  7. package/dist_ts/config/classes.target-profile-manager.d.ts +12 -1
  8. package/dist_ts/config/classes.target-profile-manager.js +98 -11
  9. package/dist_ts/dns/manager.dns.js +3 -3
  10. package/dist_ts/monitoring/classes.metricsmanager.d.ts +1 -1
  11. package/dist_ts/opsserver/handlers/certificate.handler.js +6 -9
  12. package/dist_ts/vpn/classes.vpn-manager.d.ts +11 -2
  13. package/dist_ts/vpn/classes.vpn-manager.js +120 -64
  14. package/dist_ts_interfaces/data/target-profile.d.ts +1 -1
  15. package/dist_ts_migrations/index.js +25 -18
  16. package/dist_ts_web/00_commitinfo_data.js +1 -1
  17. package/dist_ts_web/elements/network/ops-view-targetprofiles.d.ts +4 -0
  18. package/dist_ts_web/elements/network/ops-view-targetprofiles.js +46 -9
  19. package/dist_ts_web/elements/network/ops-view-vpn.d.ts +6 -7
  20. package/dist_ts_web/elements/network/ops-view-vpn.js +39 -34
  21. package/package.json +6 -6
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/classes.dcrouter.ts +37 -13
  24. package/ts/config/classes.route-config-manager.ts +80 -36
  25. package/ts/config/classes.target-profile-manager.ts +129 -6
  26. package/ts/dns/manager.dns.ts +2 -2
  27. package/ts/opsserver/handlers/certificate.handler.ts +4 -5
  28. package/ts/vpn/classes.vpn-manager.ts +146 -60
  29. package/ts_web/00_commitinfo_data.ts +1 -1
  30. package/ts_web/elements/network/ops-view-targetprofiles.ts +57 -8
  31. package/ts_web/elements/network/ops-view-vpn.ts +43 -32
@@ -315,7 +315,8 @@ export class DcRouter {
315
315
  // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
316
316
  private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
317
317
  private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
318
- private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
318
+ // Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
319
+ private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
319
320
 
320
321
  // Environment access
321
322
  private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -547,7 +548,9 @@ export class DcRouter {
547
548
  await this.referenceResolver.initialize();
548
549
 
549
550
  // Initialize target profile manager
550
- this.targetProfileManager = new TargetProfileManager();
551
+ this.targetProfileManager = new TargetProfileManager(
552
+ () => this.routeConfigManager?.getRoutes() || new Map(),
553
+ );
551
554
  await this.targetProfileManager.initialize();
552
555
 
553
556
  this.routeConfigManager = new RouteConfigManager(
@@ -560,7 +563,10 @@ export class DcRouter {
560
563
  return [];
561
564
  }
562
565
  return this.targetProfileManager.getMatchingClientIps(
563
- route, routeId, this.vpnManager.listClients(),
566
+ route,
567
+ routeId,
568
+ this.vpnManager.listClients(),
569
+ this.routeConfigManager?.getRoutes() || new Map(),
564
570
  );
565
571
  }
566
572
  : undefined,
@@ -575,14 +581,15 @@ export class DcRouter {
575
581
  this.tunnelManager.syncAllowedEdges();
576
582
  }
577
583
  },
584
+ () => this.runtimeDnsRoutes,
578
585
  );
579
586
  this.apiTokenManager = new ApiTokenManager();
580
587
  await this.apiTokenManager.initialize();
581
588
  await this.routeConfigManager.initialize(
582
589
  this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
583
590
  this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
584
- this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
585
591
  );
592
+ await this.targetProfileManager.normalizeAllRouteRefs();
586
593
 
587
594
  // Seed default profiles/targets if DB is empty and seeding is enabled
588
595
  const seeder = new DbSeeder(this.referenceResolver);
@@ -886,7 +893,7 @@ export class DcRouter {
886
893
  this.smartProxy = undefined;
887
894
  }
888
895
 
889
- // Assemble seed routes from constructor config — these will be seeded into DB
896
+ // Assemble serializable seed routes from constructor config — these will be seeded into DB
890
897
  // by RouteConfigManager.initialize() when the ConfigManagers service starts.
891
898
  this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
892
899
  logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
@@ -897,17 +904,17 @@ export class DcRouter {
897
904
  logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
898
905
  }
899
906
 
900
- this.seedDnsRoutes = [];
907
+ this.runtimeDnsRoutes = [];
901
908
  if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
902
- this.seedDnsRoutes = this.generateDnsRoutes();
903
- logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
909
+ this.runtimeDnsRoutes = this.generateDnsRoutes();
910
+ logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
904
911
  }
905
912
 
906
913
  // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
907
914
  let routes: plugins.smartproxy.IRouteConfig[] = [
908
915
  ...this.seedConfigRoutes,
909
916
  ...this.seedEmailRoutes,
910
- ...this.seedDnsRoutes,
917
+ ...this.runtimeDnsRoutes,
911
918
  ];
912
919
 
913
920
  // Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
@@ -1457,7 +1464,6 @@ export class DcRouter {
1457
1464
  await this.routeConfigManager.initialize(
1458
1465
  this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
1459
1466
  this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
1460
- this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
1461
1467
  );
1462
1468
  }
1463
1469
 
@@ -2179,7 +2185,7 @@ export class DcRouter {
2179
2185
  // Pass current bootstrap routes so the manager can derive edge ports initially.
2180
2186
  // Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
2181
2187
  // will push the complete merged routes here.
2182
- const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes];
2188
+ const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
2183
2189
  this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
2184
2190
 
2185
2191
  // If ConfigManagers finished before us, re-apply routes
@@ -2283,8 +2289,11 @@ export class DcRouter {
2283
2289
 
2284
2290
  // Resolve DNS A records for matched domains (with caching)
2285
2291
  for (const domain of domains) {
2286
- const stripped = domain.replace(/^\*\./, '');
2287
- const resolvedIps = await this.resolveVpnDomainIPs(stripped);
2292
+ if (this.isWildcardVpnDomain(domain)) {
2293
+ this.logSkippedWildcardAllowedIp(domain);
2294
+ continue;
2295
+ }
2296
+ const resolvedIps = await this.resolveVpnDomainIPs(domain);
2288
2297
  for (const ip of resolvedIps) {
2289
2298
  ips.add(`${ip}/32`);
2290
2299
  }
@@ -2303,6 +2312,8 @@ export class DcRouter {
2303
2312
 
2304
2313
  /** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
2305
2314
  private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
2315
+ /** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
2316
+ private warnedWildcardVpnDomains = new Set<string>();
2306
2317
 
2307
2318
  /**
2308
2319
  * Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
@@ -2328,6 +2339,19 @@ export class DcRouter {
2328
2339
  }
2329
2340
  }
2330
2341
 
2342
+ private isWildcardVpnDomain(domain: string): boolean {
2343
+ return domain.includes('*');
2344
+ }
2345
+
2346
+ private logSkippedWildcardAllowedIp(domain: string): void {
2347
+ if (this.warnedWildcardVpnDomains.has(domain)) return;
2348
+ this.warnedWildcardVpnDomains.add(domain);
2349
+ logger.log(
2350
+ 'warn',
2351
+ `VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
2352
+ );
2353
+ }
2354
+
2331
2355
  // VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
2332
2356
  // via the getVpnAllowList callback — no longer a separate method here.
2333
2357
 
@@ -55,6 +55,7 @@ export class RouteConfigManager {
55
55
  private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
56
56
  private referenceResolver?: ReferenceResolver,
57
57
  private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
58
+ private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
58
59
  ) {}
59
60
 
60
61
  /** Expose routes map for reference resolution lookups. */
@@ -63,7 +64,8 @@ export class RouteConfigManager {
63
64
  }
64
65
 
65
66
  /**
66
- * Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy.
67
+ * Load persisted routes, seed serializable config/email/dns routes,
68
+ * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
67
69
  */
68
70
  public async initialize(
69
71
  configRoutes: IDcRouterRouteConfig[] = [],
@@ -284,23 +286,40 @@ export class RouteConfigManager {
284
286
 
285
287
  private async loadRoutes(): Promise<void> {
286
288
  const docs = await RouteDoc.findAll();
289
+ let prunedRuntimeRoutes = 0;
290
+
287
291
  for (const doc of docs) {
288
- if (doc.id) {
289
- this.routes.set(doc.id, {
290
- id: doc.id,
291
- route: doc.route,
292
- enabled: doc.enabled,
293
- createdAt: doc.createdAt,
294
- updatedAt: doc.updatedAt,
295
- createdBy: doc.createdBy,
296
- origin: doc.origin || 'api',
297
- metadata: doc.metadata,
298
- });
292
+ if (!doc.id) continue;
293
+
294
+ const storedRoute: IRoute = {
295
+ id: doc.id,
296
+ route: doc.route,
297
+ enabled: doc.enabled,
298
+ createdAt: doc.createdAt,
299
+ updatedAt: doc.updatedAt,
300
+ createdBy: doc.createdBy,
301
+ origin: doc.origin || 'api',
302
+ metadata: doc.metadata,
303
+ };
304
+
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;
299
313
  }
314
+
315
+ this.routes.set(doc.id, storedRoute);
300
316
  }
301
317
  if (this.routes.size > 0) {
302
318
  logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
303
319
  }
320
+ if (prunedRuntimeRoutes > 0) {
321
+ logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
322
+ }
304
323
  }
305
324
 
306
325
  private async persistRoute(stored: IRoute): Promise<void> {
@@ -389,36 +408,18 @@ export class RouteConfigManager {
389
408
 
390
409
  const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
391
410
 
392
- const http3Config = this.getHttp3Config?.();
393
- const vpnCallback = this.getVpnClientIpsForRoute;
394
-
395
- // Helper: inject VPN security into a vpnOnly route
396
- const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
397
- if (!vpnCallback) return route;
398
- const dcRoute = route as IDcRouterRouteConfig;
399
- if (!dcRoute.vpnOnly) return route;
400
- const vpnEntries = vpnCallback(dcRoute, routeId);
401
- const existingEntries = route.security?.ipAllowList || [];
402
- return {
403
- ...route,
404
- security: {
405
- ...route.security,
406
- ipAllowList: [...existingEntries, ...vpnEntries],
407
- },
408
- };
409
- };
410
-
411
411
  // Add all enabled routes with HTTP/3 and VPN augmentation
412
412
  for (const route of this.routes.values()) {
413
413
  if (route.enabled) {
414
- let r = route.route;
415
- if (http3Config?.enabled !== false) {
416
- r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
417
- }
418
- enabledRoutes.push(injectVpn(r, route.id));
414
+ enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
419
415
  }
420
416
  }
421
417
 
418
+ const runtimeRoutes = this.getRuntimeRoutes?.() || [];
419
+ for (const route of runtimeRoutes) {
420
+ enabledRoutes.push(this.prepareRouteForApply(route));
421
+ }
422
+
422
423
  await smartProxy.updateRoutes(enabledRoutes);
423
424
 
424
425
  // Notify listeners (e.g. RemoteIngressManager) of the route set
@@ -429,4 +430,47 @@ export class RouteConfigManager {
429
430
  logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
430
431
  });
431
432
  }
433
+
434
+ private prepareRouteForApply(
435
+ route: plugins.smartproxy.IRouteConfig,
436
+ routeId?: string,
437
+ ): plugins.smartproxy.IRouteConfig {
438
+ let preparedRoute = route;
439
+ const http3Config = this.getHttp3Config?.();
440
+
441
+ if (http3Config?.enabled !== false) {
442
+ preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
443
+ }
444
+
445
+ return this.injectVpnSecurity(preparedRoute, routeId);
446
+ }
447
+
448
+ private injectVpnSecurity(
449
+ route: plugins.smartproxy.IRouteConfig,
450
+ routeId?: string,
451
+ ): plugins.smartproxy.IRouteConfig {
452
+ const vpnCallback = this.getVpnClientIpsForRoute;
453
+ if (!vpnCallback) return route;
454
+
455
+ const dcRoute = route as IDcRouterRouteConfig;
456
+ if (!dcRoute.vpnOnly) return route;
457
+
458
+ const vpnEntries = vpnCallback(dcRoute, routeId);
459
+ const existingEntries = route.security?.ipAllowList || [];
460
+ return {
461
+ ...route,
462
+ security: {
463
+ ...route.security,
464
+ ipAllowList: [...existingEntries, ...vpnEntries],
465
+ },
466
+ };
467
+ }
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
+ }
432
476
  }
@@ -13,6 +13,10 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js';
13
13
  export class TargetProfileManager {
14
14
  private profiles = new Map<string, ITargetProfile>();
15
15
 
16
+ constructor(
17
+ private getAllRoutes?: () => Map<string, IRoute>,
18
+ ) {}
19
+
16
20
  // =========================================================================
17
21
  // Lifecycle
18
22
  // =========================================================================
@@ -43,13 +47,14 @@ export class TargetProfileManager {
43
47
  const id = plugins.uuid.v4();
44
48
  const now = Date.now();
45
49
 
50
+ const routeRefs = this.normalizeRouteRefs(data.routeRefs);
46
51
  const profile: ITargetProfile = {
47
52
  id,
48
53
  name: data.name,
49
54
  description: data.description,
50
55
  domains: data.domains,
51
56
  targets: data.targets,
52
- routeRefs: data.routeRefs,
57
+ routeRefs,
53
58
  createdAt: now,
54
59
  updatedAt: now,
55
60
  createdBy: data.createdBy,
@@ -70,11 +75,19 @@ export class TargetProfileManager {
70
75
  throw new Error(`Target profile '${id}' not found`);
71
76
  }
72
77
 
78
+ if (patch.name !== undefined && patch.name !== profile.name) {
79
+ for (const existing of this.profiles.values()) {
80
+ if (existing.id !== id && existing.name === patch.name) {
81
+ throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
82
+ }
83
+ }
84
+ }
85
+
73
86
  if (patch.name !== undefined) profile.name = patch.name;
74
87
  if (patch.description !== undefined) profile.description = patch.description;
75
88
  if (patch.domains !== undefined) profile.domains = patch.domains;
76
89
  if (patch.targets !== undefined) profile.targets = patch.targets;
77
- if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
90
+ if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
78
91
  profile.updatedAt = Date.now();
79
92
 
80
93
  await this.persistProfile(profile);
@@ -127,6 +140,29 @@ export class TargetProfileManager {
127
140
  return this.profiles.get(id);
128
141
  }
129
142
 
143
+ /**
144
+ * Normalize stored route references to route IDs when they can be resolved
145
+ * uniquely against the current route registry.
146
+ */
147
+ public async normalizeAllRouteRefs(): Promise<void> {
148
+ const allRoutes = this.getAllRoutes?.();
149
+ if (!allRoutes?.size) return;
150
+
151
+ for (const profile of this.profiles.values()) {
152
+ const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
153
+ profile.routeRefs,
154
+ allRoutes,
155
+ 'bestEffort',
156
+ );
157
+ if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
158
+
159
+ profile.routeRefs = normalizedRouteRefs;
160
+ profile.updatedAt = Date.now();
161
+ await this.persistProfile(profile);
162
+ logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
163
+ }
164
+ }
165
+
130
166
  public listProfiles(): ITargetProfile[] {
131
167
  return [...this.profiles.values()];
132
168
  }
@@ -178,9 +214,11 @@ export class TargetProfileManager {
178
214
  route: IDcRouterRouteConfig,
179
215
  routeId: string | undefined,
180
216
  clients: VpnClientDoc[],
217
+ allRoutes: Map<string, IRoute> = new Map(),
181
218
  ): Array<string | { ip: string; domains: string[] }> {
182
219
  const entries: Array<string | { ip: string; domains: string[] }> = [];
183
220
  const routeDomains: string[] = (route.match as any)?.domains || [];
221
+ const routeNameIndex = this.buildRouteNameIndex(allRoutes);
184
222
 
185
223
  for (const client of clients) {
186
224
  if (!client.enabled || !client.assignedIp) continue;
@@ -194,7 +232,13 @@ export class TargetProfileManager {
194
232
  const profile = this.profiles.get(profileId);
195
233
  if (!profile) continue;
196
234
 
197
- const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
235
+ const matchResult = this.routeMatchesProfileDetailed(
236
+ route,
237
+ routeId,
238
+ profile,
239
+ routeDomains,
240
+ routeNameIndex,
241
+ );
198
242
  if (matchResult === 'full') {
199
243
  fullAccess = true;
200
244
  break; // No need to check more profiles
@@ -224,6 +268,7 @@ export class TargetProfileManager {
224
268
  ): { domains: string[]; targetIps: string[] } {
225
269
  const domains = new Set<string>();
226
270
  const targetIps = new Set<string>();
271
+ const routeNameIndex = this.buildRouteNameIndex(allRoutes);
227
272
 
228
273
  // Collect all access specifiers from assigned profiles
229
274
  for (const profileId of targetProfileIds) {
@@ -247,7 +292,12 @@ export class TargetProfileManager {
247
292
  // Route references: scan all routes
248
293
  for (const [routeId, route] of allRoutes) {
249
294
  if (!route.enabled) continue;
250
- if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
295
+ if (this.routeMatchesProfile(
296
+ route.route as IDcRouterRouteConfig,
297
+ routeId,
298
+ profile,
299
+ routeNameIndex,
300
+ )) {
251
301
  const routeDomains = (route.route.match as any)?.domains;
252
302
  if (Array.isArray(routeDomains)) {
253
303
  for (const d of routeDomains) {
@@ -275,9 +325,16 @@ export class TargetProfileManager {
275
325
  route: IDcRouterRouteConfig,
276
326
  routeId: string | undefined,
277
327
  profile: ITargetProfile,
328
+ routeNameIndex: Map<string, string[]>,
278
329
  ): boolean {
279
330
  const routeDomains: string[] = (route.match as any)?.domains || [];
280
- const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
331
+ const result = this.routeMatchesProfileDetailed(
332
+ route,
333
+ routeId,
334
+ profile,
335
+ routeDomains,
336
+ routeNameIndex,
337
+ );
281
338
  return result !== 'none';
282
339
  }
283
340
 
@@ -294,11 +351,17 @@ export class TargetProfileManager {
294
351
  routeId: string | undefined,
295
352
  profile: ITargetProfile,
296
353
  routeDomains: string[],
354
+ routeNameIndex: Map<string, string[]>,
297
355
  ): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
298
356
  // 1. Route reference match → full access
299
357
  if (profile.routeRefs?.length) {
300
358
  if (routeId && profile.routeRefs.includes(routeId)) return 'full';
301
- if (route.name && profile.routeRefs.includes(route.name)) return 'full';
359
+ if (routeId && route.name && profile.routeRefs.includes(route.name)) {
360
+ const matchingRouteIds = routeNameIndex.get(route.name) || [];
361
+ if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
362
+ return 'full';
363
+ }
364
+ }
302
365
  }
303
366
 
304
367
  // 2. Domain match
@@ -362,6 +425,66 @@ export class TargetProfileManager {
362
425
  return false;
363
426
  }
364
427
 
428
+ private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
429
+ const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
430
+ return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
431
+ }
432
+
433
+ private normalizeRouteRefsAgainstRoutes(
434
+ routeRefs: string[] | undefined,
435
+ allRoutes: Map<string, IRoute>,
436
+ mode: 'strict' | 'bestEffort',
437
+ ): string[] | undefined {
438
+ if (!routeRefs?.length) return undefined;
439
+ if (!allRoutes.size) return [...new Set(routeRefs)];
440
+
441
+ const routeNameIndex = this.buildRouteNameIndex(allRoutes);
442
+ const normalizedRefs = new Set<string>();
443
+
444
+ for (const routeRef of routeRefs) {
445
+ if (allRoutes.has(routeRef)) {
446
+ normalizedRefs.add(routeRef);
447
+ continue;
448
+ }
449
+
450
+ const matchingRouteIds = routeNameIndex.get(routeRef) || [];
451
+ if (matchingRouteIds.length === 1) {
452
+ normalizedRefs.add(matchingRouteIds[0]);
453
+ continue;
454
+ }
455
+
456
+ if (mode === 'bestEffort') {
457
+ normalizedRefs.add(routeRef);
458
+ continue;
459
+ }
460
+
461
+ if (matchingRouteIds.length > 1) {
462
+ throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
463
+ }
464
+ throw new Error(`Route reference '${routeRef}' not found`);
465
+ }
466
+
467
+ return [...normalizedRefs];
468
+ }
469
+
470
+ private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
471
+ const routeNameIndex = new Map<string, string[]>();
472
+ for (const [routeId, route] of allRoutes) {
473
+ const routeName = route.route.name;
474
+ if (!routeName) continue;
475
+ const matchingRouteIds = routeNameIndex.get(routeName) || [];
476
+ matchingRouteIds.push(routeId);
477
+ routeNameIndex.set(routeName, matchingRouteIds);
478
+ }
479
+ return routeNameIndex;
480
+ }
481
+
482
+ private sameStringArray(left?: string[], right?: string[]): boolean {
483
+ if (!left?.length && !right?.length) return true;
484
+ if (!left || !right || left.length !== right.length) return false;
485
+ return left.every((value, index) => value === right[index]);
486
+ }
487
+
365
488
  // =========================================================================
366
489
  // Private: persistence
367
490
  // =========================================================================
@@ -97,8 +97,8 @@ export class DnsManager {
97
97
  if (hasLegacyConfig) {
98
98
  logger.log(
99
99
  'warn',
100
- 'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
101
- 'Manage DNS via the Domains UI instead.',
100
+ 'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
101
+ 'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
102
102
  );
103
103
  }
104
104
  return;
@@ -198,12 +198,11 @@ export class CertificateHandler {
198
198
  try {
199
199
  const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
200
200
  if (rustStatus) {
201
- if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
202
- if (rustStatus.issuer) issuer = rustStatus.issuer;
203
- if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
204
- if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
205
- status = rustStatus.status;
201
+ if (rustStatus.expiresAt > 0) {
202
+ expiryDate = new Date(rustStatus.expiresAt).toISOString();
206
203
  }
204
+ if (rustStatus.source) issuer = rustStatus.source;
205
+ status = rustStatus.isValid ? 'valid' : 'expired';
207
206
  }
208
207
  } catch {
209
208
  // Rust bridge may not support this command yet — ignore