@serve.zone/dcrouter 13.17.3 → 13.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  // =========================================================================
@@ -55,6 +55,8 @@ export class VpnManager {
55
55
  private vpnServer?: plugins.smartvpn.VpnServer;
56
56
  private clients: Map<string, VpnClientDoc> = new Map();
57
57
  private serverKeys?: VpnServerKeysDoc;
58
+ private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
59
+ private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
58
60
 
59
61
  constructor(config: IVpnManagerConfig) {
60
62
  this.config = config;
@@ -88,6 +90,7 @@ export class VpnManager {
88
90
  if (client.useHostIp) {
89
91
  anyClientUsesHostIp = true;
90
92
  }
93
+ this.normalizeClientRoutingSettings(client);
91
94
  const entry: plugins.smartvpn.IClientEntry = {
92
95
  clientId: client.clientId,
93
96
  publicKey: client.noisePublicKey,
@@ -97,13 +100,12 @@ export class VpnManager {
97
100
  assignedIp: client.assignedIp,
98
101
  expiresAt: client.expiresAt,
99
102
  security: this.buildClientSecurity(client),
103
+ useHostIp: client.useHostIp,
104
+ useDhcp: client.useDhcp,
105
+ staticIp: client.staticIp,
106
+ forceVlan: client.forceVlan,
107
+ vlanId: client.vlanId,
100
108
  };
101
- // Pass per-client bridge fields if present (for hybrid/bridge mode)
102
- if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
103
- if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
104
- if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
105
- if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
106
- if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
107
109
  clientEntries.push(entry);
108
110
  }
109
111
 
@@ -112,13 +114,15 @@ export class VpnManager {
112
114
 
113
115
  // Auto-detect hybrid mode: if any persisted client uses host IP and mode is
114
116
  // 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
115
- let configuredMode = this.config.forwardingMode ?? 'socket';
117
+ let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
116
118
  if (anyClientUsesHostIp && configuredMode === 'socket') {
117
119
  configuredMode = 'hybrid';
118
120
  logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
119
121
  }
120
122
  const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
121
123
  const isBridge = forwardingMode === 'bridge';
124
+ this.resolvedForwardingMode = forwardingMode;
125
+ this.forwardingModeOverride = undefined;
122
126
 
123
127
  // Create and start VpnServer
124
128
  this.vpnServer = new plugins.smartvpn.VpnServer({
@@ -143,7 +147,7 @@ export class VpnManager {
143
147
  wgListenPort,
144
148
  clients: clientEntries,
145
149
  socketForwardProxyProtocol: !isBridge,
146
- destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
150
+ destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
147
151
  serverEndpoint: this.config.serverEndpoint
148
152
  ? `${this.config.serverEndpoint}:${wgListenPort}`
149
153
  : undefined,
@@ -189,6 +193,7 @@ export class VpnManager {
189
193
  this.vpnServer.stop();
190
194
  this.vpnServer = undefined;
191
195
  }
196
+ this.resolvedForwardingMode = undefined;
192
197
  logger.log('info', 'VPN server stopped');
193
198
  }
194
199
 
@@ -213,14 +218,38 @@ export class VpnManager {
213
218
  throw new Error('VPN server not running');
214
219
  }
215
220
 
221
+ await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
222
+
223
+ const doc = new VpnClientDoc();
224
+ doc.clientId = opts.clientId;
225
+ doc.enabled = true;
226
+ doc.targetProfileIds = opts.targetProfileIds;
227
+ doc.description = opts.description;
228
+ doc.destinationAllowList = opts.destinationAllowList;
229
+ doc.destinationBlockList = opts.destinationBlockList;
230
+ doc.useHostIp = opts.useHostIp;
231
+ doc.useDhcp = opts.useDhcp;
232
+ doc.staticIp = opts.staticIp;
233
+ doc.forceVlan = opts.forceVlan;
234
+ doc.vlanId = opts.vlanId;
235
+ doc.createdAt = Date.now();
236
+ doc.updatedAt = Date.now();
237
+ this.normalizeClientRoutingSettings(doc);
238
+
216
239
  const bundle = await this.vpnServer.createClient({
217
- clientId: opts.clientId,
218
- description: opts.description,
240
+ clientId: doc.clientId,
241
+ description: doc.description,
242
+ security: this.buildClientSecurity(doc),
243
+ useHostIp: doc.useHostIp,
244
+ useDhcp: doc.useDhcp,
245
+ staticIp: doc.staticIp,
246
+ forceVlan: doc.forceVlan,
247
+ vlanId: doc.vlanId,
219
248
  });
220
249
 
221
250
  // Override AllowedIPs with per-client values based on target profiles
222
251
  if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
223
- const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
252
+ const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
224
253
  bundle.wireguardConfig = bundle.wireguardConfig.replace(
225
254
  /AllowedIPs\s*=\s*.+/,
226
255
  `AllowedIPs = ${allowedIPs.join(', ')}`,
@@ -228,40 +257,16 @@ export class VpnManager {
228
257
  }
229
258
 
230
259
  // Persist client entry (including WG private key for export/QR)
231
- const doc = new VpnClientDoc();
232
260
  doc.clientId = bundle.entry.clientId;
233
261
  doc.enabled = bundle.entry.enabled ?? true;
234
- doc.targetProfileIds = opts.targetProfileIds;
235
262
  doc.description = bundle.entry.description;
236
263
  doc.assignedIp = bundle.entry.assignedIp;
237
264
  doc.noisePublicKey = bundle.entry.publicKey;
238
265
  doc.wgPublicKey = bundle.entry.wgPublicKey || '';
239
266
  doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
240
267
  || bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
241
- doc.createdAt = Date.now();
242
268
  doc.updatedAt = Date.now();
243
269
  doc.expiresAt = bundle.entry.expiresAt;
244
- if (opts.destinationAllowList !== undefined) {
245
- doc.destinationAllowList = opts.destinationAllowList;
246
- }
247
- if (opts.destinationBlockList !== undefined) {
248
- doc.destinationBlockList = opts.destinationBlockList;
249
- }
250
- if (opts.useHostIp !== undefined) {
251
- doc.useHostIp = opts.useHostIp;
252
- }
253
- if (opts.useDhcp !== undefined) {
254
- doc.useDhcp = opts.useDhcp;
255
- }
256
- if (opts.staticIp !== undefined) {
257
- doc.staticIp = opts.staticIp;
258
- }
259
- if (opts.forceVlan !== undefined) {
260
- doc.forceVlan = opts.forceVlan;
261
- }
262
- if (opts.vlanId !== undefined) {
263
- doc.vlanId = opts.vlanId;
264
- }
265
270
  this.clients.set(doc.clientId, doc);
266
271
  try {
267
272
  await this.persistClient(doc);
@@ -276,12 +281,6 @@ export class VpnManager {
276
281
  throw err;
277
282
  }
278
283
 
279
- // Sync per-client security to the running daemon
280
- const security = this.buildClientSecurity(doc);
281
- if (security.destinationPolicy) {
282
- await this.vpnServer!.updateClient(doc.clientId, { security });
283
- }
284
-
285
284
  this.config.onClientChanged?.();
286
285
  return bundle;
287
286
  }
@@ -364,13 +363,13 @@ export class VpnManager {
364
363
  if (update.staticIp !== undefined) client.staticIp = update.staticIp;
365
364
  if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
366
365
  if (update.vlanId !== undefined) client.vlanId = update.vlanId;
366
+ this.normalizeClientRoutingSettings(client);
367
367
  client.updatedAt = Date.now();
368
368
  await this.persistClient(client);
369
369
 
370
- // Sync per-client security to the running daemon
371
370
  if (this.vpnServer) {
372
- const security = this.buildClientSecurity(client);
373
- await this.vpnServer.updateClient(clientId, { security });
371
+ await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
372
+ await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
374
373
  }
375
374
 
376
375
  this.config.onClientChanged?.();
@@ -478,26 +477,28 @@ export class VpnManager {
478
477
 
479
478
  /**
480
479
  * Build per-client security settings for the smartvpn daemon.
481
- * All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
482
- * TargetProfile direct IP:port targets bypass SmartProxy via allowList.
480
+ * TargetProfile direct IP:port targets extend the effective allow-list.
483
481
  */
484
482
  private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
485
483
  const security: plugins.smartvpn.IClientSecurity = {};
484
+ const basePolicy = this.getBaseDestinationPolicy(client);
486
485
 
487
- // Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
488
486
  const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
489
-
490
- // Merge with per-client explicit allow list
491
- const mergedAllowList = [
492
- ...(client.destinationAllowList || []),
493
- ...profileDirectTargets,
494
- ];
487
+ const mergedAllowList = this.mergeDestinationLists(
488
+ basePolicy.allowList,
489
+ client.destinationAllowList,
490
+ profileDirectTargets,
491
+ );
492
+ const mergedBlockList = this.mergeDestinationLists(
493
+ basePolicy.blockList,
494
+ client.destinationBlockList,
495
+ );
495
496
 
496
497
  security.destinationPolicy = {
497
- default: 'forceTarget' as const,
498
- target: '127.0.0.1',
498
+ default: basePolicy.default,
499
+ target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
499
500
  allowList: mergedAllowList.length ? mergedAllowList : undefined,
500
- blockList: client.destinationBlockList,
501
+ blockList: mergedBlockList.length ? mergedBlockList : undefined,
501
502
  };
502
503
 
503
504
  return security;
@@ -510,10 +511,7 @@ export class VpnManager {
510
511
  public async refreshAllClientSecurity(): Promise<void> {
511
512
  if (!this.vpnServer) return;
512
513
  for (const client of this.clients.values()) {
513
- const security = this.buildClientSecurity(client);
514
- if (security.destinationPolicy) {
515
- await this.vpnServer.updateClient(client.clientId, { security });
516
- }
514
+ await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
517
515
  }
518
516
  }
519
517
 
@@ -550,6 +548,7 @@ export class VpnManager {
550
548
  private async loadPersistedClients(): Promise<void> {
551
549
  const docs = await VpnClientDoc.findAll();
552
550
  for (const doc of docs) {
551
+ this.normalizeClientRoutingSettings(doc);
553
552
  this.clients.set(doc.clientId, doc);
554
553
  }
555
554
  if (this.clients.size > 0) {
@@ -557,6 +556,93 @@ export class VpnManager {
557
556
  }
558
557
  }
559
558
 
559
+ private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
560
+ return this.resolvedForwardingMode
561
+ ?? this.forwardingModeOverride
562
+ ?? this.config.forwardingMode
563
+ ?? 'socket';
564
+ }
565
+
566
+ private getDefaultDestinationPolicy(
567
+ forwardingMode: 'socket' | 'bridge' | 'hybrid',
568
+ useHostIp = false,
569
+ ): plugins.smartvpn.IDestinationPolicy {
570
+ if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
571
+ return { default: 'allow' };
572
+ }
573
+ return { default: 'forceTarget', target: '127.0.0.1' };
574
+ }
575
+
576
+ private getServerDestinationPolicy(
577
+ forwardingMode: 'socket' | 'bridge' | 'hybrid',
578
+ fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
579
+ ): plugins.smartvpn.IDestinationPolicy {
580
+ return this.config.destinationPolicy ?? fallbackPolicy;
581
+ }
582
+
583
+ private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
584
+ if (this.config.destinationPolicy) {
585
+ return { ...this.config.destinationPolicy };
586
+ }
587
+ return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
588
+ }
589
+
590
+ private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
591
+ const merged = new Set<string>();
592
+ for (const list of lists) {
593
+ for (const entry of list || []) {
594
+ merged.add(entry);
595
+ }
596
+ }
597
+ return [...merged];
598
+ }
599
+
600
+ private normalizeClientRoutingSettings(
601
+ client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
602
+ ): void {
603
+ client.useHostIp = client.useHostIp === true;
604
+
605
+ if (!client.useHostIp) {
606
+ client.useDhcp = false;
607
+ client.staticIp = undefined;
608
+ client.forceVlan = false;
609
+ client.vlanId = undefined;
610
+ return;
611
+ }
612
+
613
+ client.useDhcp = client.useDhcp === true;
614
+ if (client.useDhcp) {
615
+ client.staticIp = undefined;
616
+ }
617
+
618
+ client.forceVlan = client.forceVlan === true;
619
+ if (!client.forceVlan) {
620
+ client.vlanId = undefined;
621
+ }
622
+ }
623
+
624
+ private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
625
+ return {
626
+ description: client.description,
627
+ security: this.buildClientSecurity(client),
628
+ useHostIp: client.useHostIp,
629
+ useDhcp: client.useDhcp,
630
+ staticIp: client.staticIp,
631
+ forceVlan: client.forceVlan,
632
+ vlanId: client.vlanId,
633
+ };
634
+ }
635
+
636
+ private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
637
+ if (!useHostIp || !this.vpnServer) return;
638
+ if (this.getResolvedForwardingMode() !== 'socket') return;
639
+
640
+ logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
641
+ this.forwardingModeOverride = 'hybrid';
642
+ await this.stop();
643
+ await this.start();
644
+ }
645
+
560
646
  private async persistClient(client: VpnClientDoc): Promise<void> {
561
647
  await client.save();
562
648
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.17.3',
6
+ version: '13.17.5',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
95
95
  ? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
96
96
  : '-',
97
97
  'Route Refs': profile.routeRefs?.length
98
- ? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
98
+ ? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
99
99
  : '-',
100
100
  Created: new Date(profile.createdAt).toLocaleDateString(),
101
101
  })}
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
149
149
  `;
150
150
  }
151
151
 
152
- private getRouteCandidates() {
152
+ private getRouteChoices() {
153
153
  const routeState = appstate.routeManagementStatePart.getState();
154
154
  const routes = routeState?.mergedRoutes || [];
155
155
  return routes
156
- .filter((mr) => mr.route.name)
157
- .map((mr) => ({ viewKey: mr.route.name! }));
156
+ .filter((mr) => mr.route.name && mr.id)
157
+ .map((mr) => ({
158
+ routeId: mr.id!,
159
+ routeName: mr.route.name!,
160
+ label: `${mr.route.name} (${mr.id})`,
161
+ }));
162
+ }
163
+
164
+ private getRouteCandidates() {
165
+ return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
166
+ }
167
+
168
+ private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
169
+ if (!routeRefs?.length) return undefined;
170
+
171
+ const routeChoices = this.getRouteChoices();
172
+ const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
173
+ const routeByName = new Map<string, string[]>();
174
+
175
+ for (const route of routeChoices) {
176
+ const labels = routeByName.get(route.routeName) || [];
177
+ labels.push(route.label);
178
+ routeByName.set(route.routeName, labels);
179
+ }
180
+
181
+ return routeRefs.map((routeRef) => {
182
+ const routeLabel = routeById.get(routeRef);
183
+ if (routeLabel) return routeLabel;
184
+
185
+ const labelsForName = routeByName.get(routeRef) || [];
186
+ if (labelsForName.length === 1) return labelsForName[0];
187
+
188
+ return routeRef;
189
+ });
190
+ }
191
+
192
+ private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
193
+ if (!routeRefs.length) return [];
194
+
195
+ const labelToId = new Map(
196
+ this.getRouteChoices().map((route) => [route.label, route.routeId]),
197
+ );
198
+ return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
199
+ }
200
+
201
+ private formatRouteRef(routeRef: string): string {
202
+ return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
158
203
  }
159
204
 
160
205
  private async ensureRoutesLoaded() {
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
203
248
  };
204
249
  })
205
250
  .filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
206
- const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
251
+ const routeRefs = this.resolveRouteLabelsToRefs(
252
+ Array.isArray(data.routeRefs) ? data.routeRefs : [],
253
+ );
207
254
 
208
255
  await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
209
256
  name: String(data.name),
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
222
269
  private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
223
270
  const currentDomains = profile.domains || [];
224
271
  const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
225
- const currentRouteRefs = profile.routeRefs || [];
272
+ const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
226
273
 
227
274
  const { DeesModal } = await import('@design.estate/dees-catalog');
228
275
  await this.ensureRoutesLoaded();
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
261
308
  };
262
309
  })
263
310
  .filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
264
- const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
311
+ const routeRefs = this.resolveRouteLabelsToRefs(
312
+ Array.isArray(data.routeRefs) ? data.routeRefs : [],
313
+ );
265
314
 
266
315
  await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
267
316
  id: profile.id,
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
336
385
  <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
337
386
  <div style="font-size: 14px; margin-top: 4px;">
338
387
  ${profile.routeRefs?.length
339
- ? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
388
+ ? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
340
389
  : '-'}
341
390
  </div>
342
391
  </div>