@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
@@ -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.8',
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>
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
28
28
  const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
29
29
  const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
30
30
  const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
31
- if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
31
+ if (hostIpGroup) hostIpGroup.style.display = show;
32
32
  if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
33
33
  if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
34
34
  if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
390
390
  if (!form) return;
391
391
  const data = await form.collectFormData();
392
392
  if (!data.clientId) return;
393
- const targetProfileIds = this.resolveProfileNamesToIds(
393
+ const targetProfileIds = this.resolveProfileLabelsToIds(
394
394
  Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
395
395
  );
396
396
 
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
414
414
  description: data.description || undefined,
415
415
  targetProfileIds,
416
416
 
417
- useHostIp: useHostIp || undefined,
418
- useDhcp: useDhcp || undefined,
417
+ useHostIp,
418
+ useDhcp,
419
419
  staticIp,
420
- forceVlan: forceVlan || undefined,
420
+ forceVlan,
421
421
  vlanId,
422
422
  destinationAllowList,
423
423
  destinationBlockList,
@@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
485
485
  <div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
486
486
  ` : ''}
487
487
  <div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
488
- <div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
488
+ <div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
489
489
  <div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
490
490
  ${client.useHostIp ? html`
491
491
  <div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
649
649
  const client = actionData.item as interfaces.data.IVpnClient;
650
650
  const { DeesModal } = await import('@design.estate/dees-catalog');
651
651
  const currentDescription = client.description ?? '';
652
- const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
652
+ const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
653
653
  const profileCandidates = this.getTargetProfileCandidates();
654
654
  const currentUseHostIp = client.useHostIp ?? false;
655
655
  const currentUseDhcp = client.useDhcp ?? false;
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
695
695
  const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
696
696
  if (!form) return;
697
697
  const data = await form.collectFormData();
698
- const targetProfileIds = this.resolveProfileNamesToIds(
698
+ const targetProfileIds = this.resolveProfileLabelsToIds(
699
699
  Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
700
700
  );
701
701
 
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
719
719
  description: data.description || undefined,
720
720
  targetProfileIds,
721
721
 
722
- useHostIp: useHostIp || undefined,
723
- useDhcp: useDhcp || undefined,
722
+ useHostIp,
723
+ useDhcp,
724
724
  staticIp,
725
- forceVlan: forceVlan || undefined,
725
+ forceVlan,
726
726
  vlanId,
727
727
  destinationAllowList,
728
728
  destinationBlockList,
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
811
811
  }
812
812
 
813
813
  /**
814
- * Build autocomplete candidates from loaded target profiles.
815
- * viewKey = profile name (displayed), payload = { id } (carried for resolution).
814
+ * Build stable profile labels for list inputs.
816
815
  */
817
- private getTargetProfileCandidates() {
816
+ private getTargetProfileChoices() {
818
817
  const profileState = appstate.targetProfilesStatePart.getState();
819
818
  const profiles = profileState?.profiles || [];
820
- return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
819
+ const nameCounts = new Map<string, number>();
820
+
821
+ for (const profile of profiles) {
822
+ nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1);
823
+ }
824
+
825
+ return profiles.map((profile) => ({
826
+ id: profile.id,
827
+ label: (nameCounts.get(profile.name) || 0) > 1
828
+ ? `${profile.name} (${profile.id})`
829
+ : profile.name,
830
+ }));
831
+ }
832
+
833
+ private getTargetProfileCandidates() {
834
+ return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label }));
821
835
  }
822
836
 
823
837
  /**
824
- * Convert profile IDs to profile names (for populating edit form values).
838
+ * Convert profile IDs to form labels (for populating edit form values).
825
839
  */
826
- private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
840
+ private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
827
841
  if (!ids?.length) return undefined;
828
- const profileState = appstate.targetProfilesStatePart.getState();
829
- const profiles = profileState?.profiles || [];
842
+ const choices = this.getTargetProfileChoices();
843
+ const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
830
844
  return ids.map((id) => {
831
- const profile = profiles.find((p) => p.id === id);
832
- return profile?.name || id;
845
+ return labelsById.get(id) || id;
833
846
  });
834
847
  }
835
848
 
836
849
  /**
837
- * Convert profile names back to IDs (for saving form data).
838
- * Uses the dees-input-list candidates' payload when available.
850
+ * Convert profile form labels back to IDs.
839
851
  */
840
- private resolveProfileNamesToIds(names: string[]): string[] | undefined {
841
- if (!names.length) return undefined;
842
- const profileState = appstate.targetProfilesStatePart.getState();
843
- const profiles = profileState?.profiles || [];
844
- return names
845
- .map((name) => {
846
- const profile = profiles.find((p) => p.name === name);
847
- return profile?.id;
848
- })
852
+ private resolveProfileLabelsToIds(labels: string[]): string[] {
853
+ if (!labels.length) return [];
854
+
855
+ const labelsToIds = new Map(
856
+ this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
857
+ );
858
+ return labels
859
+ .map((label) => labelsToIds.get(label))
849
860
  .filter((id): id is string => !!id);
850
861
  }
851
862
  }