@serve.zone/dcrouter 12.0.0 → 12.1.0

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.
@@ -30,6 +30,17 @@ export interface IVpnManagerConfig {
30
30
  * Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
31
31
  * When not set, defaults to [subnet]. */
32
32
  getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
33
+ /** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
34
+ * or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
35
+ forwardingMode?: 'socket' | 'bridge' | 'hybrid';
36
+ /** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
37
+ bridgeLanSubnet?: string;
38
+ /** Physical network interface for bridge mode (auto-detected if omitted) */
39
+ bridgePhysicalInterface?: string;
40
+ /** Start of VPN client IP range in LAN subnet (host offset, default: 200) */
41
+ bridgeIpRangeStart?: number;
42
+ /** End of VPN client IP range in LAN subnet (host offset, default: 250) */
43
+ bridgeIpRangeEnd?: number;
33
44
  }
34
45
 
35
46
  /**
@@ -69,8 +80,12 @@ export class VpnManager {
69
80
 
70
81
  // Build client entries for the daemon
71
82
  const clientEntries: plugins.smartvpn.IClientEntry[] = [];
83
+ let anyClientUsesHostIp = false;
72
84
  for (const client of this.clients.values()) {
73
- clientEntries.push({
85
+ if (client.useHostIp) {
86
+ anyClientUsesHostIp = true;
87
+ }
88
+ const entry: plugins.smartvpn.IClientEntry = {
74
89
  clientId: client.clientId,
75
90
  publicKey: client.noisePublicKey,
76
91
  wgPublicKey: client.wgPublicKey,
@@ -79,35 +94,65 @@ export class VpnManager {
79
94
  description: client.description,
80
95
  assignedIp: client.assignedIp,
81
96
  expiresAt: client.expiresAt,
82
- });
97
+ security: this.buildClientSecurity(client),
98
+ };
99
+ // Pass per-client bridge fields if present (for hybrid/bridge mode)
100
+ if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
101
+ if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
102
+ if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
103
+ if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
104
+ if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
105
+ clientEntries.push(entry);
83
106
  }
84
107
 
85
108
  const subnet = this.getSubnet();
86
109
  const wgListenPort = this.config.wgListenPort ?? 51820;
87
110
 
111
+ // Auto-detect hybrid mode: if any persisted client uses host IP and mode is
112
+ // 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
113
+ let configuredMode = this.config.forwardingMode ?? 'socket';
114
+ if (anyClientUsesHostIp && configuredMode === 'socket') {
115
+ configuredMode = 'hybrid';
116
+ logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
117
+ }
118
+ const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
119
+ const isBridge = forwardingMode === 'bridge';
120
+
88
121
  // Create and start VpnServer
89
122
  this.vpnServer = new plugins.smartvpn.VpnServer({
90
123
  transport: { transport: 'stdio' },
91
124
  });
92
125
 
126
+ // Default destination policy: bridge mode allows traffic through directly,
127
+ // socket mode forces traffic to SmartProxy on 127.0.0.1
128
+ const defaultDestinationPolicy: plugins.smartvpn.IDestinationPolicy = isBridge
129
+ ? { default: 'allow' as const }
130
+ : { default: 'forceTarget' as const, target: '127.0.0.1' };
131
+
93
132
  const serverConfig: plugins.smartvpn.IVpnServerConfig = {
94
133
  listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
95
134
  privateKey: this.serverKeys.noisePrivateKey,
96
135
  publicKey: this.serverKeys.noisePublicKey,
97
136
  subnet,
98
137
  dns: this.config.dns,
99
- forwardingMode: 'socket',
138
+ forwardingMode: forwardingMode as any,
100
139
  transportMode: 'all',
101
140
  wgPrivateKey: this.serverKeys.wgPrivateKey,
102
141
  wgListenPort,
103
142
  clients: clientEntries,
104
- socketForwardProxyProtocol: true,
105
- destinationPolicy: this.config.destinationPolicy
106
- ?? { default: 'forceTarget' as const, target: '127.0.0.1' },
143
+ socketForwardProxyProtocol: !isBridge,
144
+ destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
107
145
  serverEndpoint: this.config.serverEndpoint
108
146
  ? `${this.config.serverEndpoint}:${wgListenPort}`
109
147
  : undefined,
110
148
  clientAllowedIPs: [subnet],
149
+ // Bridge-specific config
150
+ ...(isBridge ? {
151
+ bridgeLanSubnet: this.config.bridgeLanSubnet,
152
+ bridgePhysicalInterface: this.config.bridgePhysicalInterface,
153
+ bridgeIpRangeStart: this.config.bridgeIpRangeStart,
154
+ bridgeIpRangeEnd: this.config.bridgeIpRangeEnd,
155
+ } : {}),
111
156
  };
112
157
 
113
158
  await this.vpnServer.start(serverConfig);
@@ -154,6 +199,14 @@ export class VpnManager {
154
199
  clientId: string;
155
200
  serverDefinedClientTags?: string[];
156
201
  description?: string;
202
+ forceDestinationSmartproxy?: boolean;
203
+ destinationAllowList?: string[];
204
+ destinationBlockList?: string[];
205
+ useHostIp?: boolean;
206
+ useDhcp?: boolean;
207
+ staticIp?: string;
208
+ forceVlan?: boolean;
209
+ vlanId?: number;
157
210
  }): Promise<plugins.smartvpn.IClientConfigBundle> {
158
211
  if (!this.vpnServer) {
159
212
  throw new Error('VPN server not running');
@@ -188,9 +241,39 @@ export class VpnManager {
188
241
  doc.createdAt = Date.now();
189
242
  doc.updatedAt = Date.now();
190
243
  doc.expiresAt = bundle.entry.expiresAt;
244
+ if (opts.forceDestinationSmartproxy !== undefined) {
245
+ doc.forceDestinationSmartproxy = opts.forceDestinationSmartproxy;
246
+ }
247
+ if (opts.destinationAllowList !== undefined) {
248
+ doc.destinationAllowList = opts.destinationAllowList;
249
+ }
250
+ if (opts.destinationBlockList !== undefined) {
251
+ doc.destinationBlockList = opts.destinationBlockList;
252
+ }
253
+ if (opts.useHostIp !== undefined) {
254
+ doc.useHostIp = opts.useHostIp;
255
+ }
256
+ if (opts.useDhcp !== undefined) {
257
+ doc.useDhcp = opts.useDhcp;
258
+ }
259
+ if (opts.staticIp !== undefined) {
260
+ doc.staticIp = opts.staticIp;
261
+ }
262
+ if (opts.forceVlan !== undefined) {
263
+ doc.forceVlan = opts.forceVlan;
264
+ }
265
+ if (opts.vlanId !== undefined) {
266
+ doc.vlanId = opts.vlanId;
267
+ }
191
268
  this.clients.set(doc.clientId, doc);
192
269
  await this.persistClient(doc);
193
270
 
271
+ // Sync per-client security to the running daemon
272
+ const security = this.buildClientSecurity(doc);
273
+ if (security.destinationPolicy) {
274
+ await this.vpnServer!.updateClient(doc.clientId, { security });
275
+ }
276
+
194
277
  this.config.onClientChanged?.();
195
278
  return bundle;
196
279
  }
@@ -254,13 +337,36 @@ export class VpnManager {
254
337
  public async updateClient(clientId: string, update: {
255
338
  description?: string;
256
339
  serverDefinedClientTags?: string[];
340
+ forceDestinationSmartproxy?: boolean;
341
+ destinationAllowList?: string[];
342
+ destinationBlockList?: string[];
343
+ useHostIp?: boolean;
344
+ useDhcp?: boolean;
345
+ staticIp?: string;
346
+ forceVlan?: boolean;
347
+ vlanId?: number;
257
348
  }): Promise<void> {
258
349
  const client = this.clients.get(clientId);
259
350
  if (!client) throw new Error(`Client not found: ${clientId}`);
260
351
  if (update.description !== undefined) client.description = update.description;
261
352
  if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
353
+ if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
354
+ if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
355
+ if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
356
+ if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
357
+ if (update.useDhcp !== undefined) client.useDhcp = update.useDhcp;
358
+ if (update.staticIp !== undefined) client.staticIp = update.staticIp;
359
+ if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
360
+ if (update.vlanId !== undefined) client.vlanId = update.vlanId;
262
361
  client.updatedAt = Date.now();
263
362
  await this.persistClient(client);
363
+
364
+ // Sync per-client security to the running daemon
365
+ if (this.vpnServer) {
366
+ const security = this.buildClientSecurity(client);
367
+ await this.vpnServer.updateClient(clientId, { security });
368
+ }
369
+
264
370
  this.config.onClientChanged?.();
265
371
  }
266
372
 
@@ -378,6 +484,37 @@ export class VpnManager {
378
484
  };
379
485
  }
380
486
 
487
+ // ── Per-client security ────────────────────────────────────────────────
488
+
489
+ /**
490
+ * Build per-client security settings for the smartvpn daemon.
491
+ * Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists)
492
+ * to smartvpn's IClientSecurity with a destinationPolicy.
493
+ */
494
+ private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
495
+ const security: plugins.smartvpn.IClientSecurity = {};
496
+ const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
497
+
498
+ if (!forceSmartproxy) {
499
+ // Client traffic goes directly — not forced to SmartProxy
500
+ security.destinationPolicy = {
501
+ default: 'allow' as const,
502
+ blockList: client.destinationBlockList,
503
+ };
504
+ } else if (client.destinationAllowList?.length || client.destinationBlockList?.length) {
505
+ // Client is forced to SmartProxy, but with per-client allow/block overrides
506
+ security.destinationPolicy = {
507
+ default: 'forceTarget' as const,
508
+ target: '127.0.0.1',
509
+ allowList: client.destinationAllowList,
510
+ blockList: client.destinationBlockList,
511
+ };
512
+ }
513
+ // else: no per-client policy, server-wide applies
514
+
515
+ return security;
516
+ }
517
+
381
518
  // ── Private helpers ────────────────────────────────────────────────────
382
519
 
383
520
  private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '12.0.0',
6
+ version: '12.1.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -984,6 +984,14 @@ export const createVpnClientAction = vpnStatePart.createAction<{
984
984
  clientId: string;
985
985
  serverDefinedClientTags?: string[];
986
986
  description?: string;
987
+ forceDestinationSmartproxy?: boolean;
988
+ destinationAllowList?: string[];
989
+ destinationBlockList?: string[];
990
+ useHostIp?: boolean;
991
+ useDhcp?: boolean;
992
+ staticIp?: string;
993
+ forceVlan?: boolean;
994
+ vlanId?: number;
987
995
  }>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
988
996
  const context = getActionContext();
989
997
  const currentState = statePartArg.getState()!;
@@ -998,6 +1006,14 @@ export const createVpnClientAction = vpnStatePart.createAction<{
998
1006
  clientId: dataArg.clientId,
999
1007
  serverDefinedClientTags: dataArg.serverDefinedClientTags,
1000
1008
  description: dataArg.description,
1009
+ forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
1010
+ destinationAllowList: dataArg.destinationAllowList,
1011
+ destinationBlockList: dataArg.destinationBlockList,
1012
+ useHostIp: dataArg.useHostIp,
1013
+ useDhcp: dataArg.useDhcp,
1014
+ staticIp: dataArg.staticIp,
1015
+ forceVlan: dataArg.forceVlan,
1016
+ vlanId: dataArg.vlanId,
1001
1017
  });
1002
1018
 
1003
1019
  if (!response.success) {
@@ -1066,6 +1082,14 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
1066
1082
  clientId: string;
1067
1083
  description?: string;
1068
1084
  serverDefinedClientTags?: string[];
1085
+ forceDestinationSmartproxy?: boolean;
1086
+ destinationAllowList?: string[];
1087
+ destinationBlockList?: string[];
1088
+ useHostIp?: boolean;
1089
+ useDhcp?: boolean;
1090
+ staticIp?: string;
1091
+ forceVlan?: boolean;
1092
+ vlanId?: number;
1069
1093
  }>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
1070
1094
  const context = getActionContext();
1071
1095
  const currentState = statePartArg.getState()!;
@@ -1080,6 +1104,14 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
1080
1104
  clientId: dataArg.clientId,
1081
1105
  description: dataArg.description,
1082
1106
  serverDefinedClientTags: dataArg.serverDefinedClientTags,
1107
+ forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
1108
+ destinationAllowList: dataArg.destinationAllowList,
1109
+ destinationBlockList: dataArg.destinationBlockList,
1110
+ useHostIp: dataArg.useHostIp,
1111
+ useDhcp: dataArg.useDhcp,
1112
+ staticIp: dataArg.staticIp,
1113
+ forceVlan: dataArg.forceVlan,
1114
+ vlanId: dataArg.vlanId,
1083
1115
  });
1084
1116
 
1085
1117
  if (!response.success) {
@@ -13,6 +13,31 @@ import * as interfaces from '../../dist_ts_interfaces/index.js';
13
13
  import { viewHostCss } from './shared/css.js';
14
14
  import { type IStatsTile } from '@design.estate/dees-catalog';
15
15
 
16
+ /**
17
+ * Toggle form field visibility based on checkbox states.
18
+ * Used in Create and Edit VPN client dialogs.
19
+ */
20
+ function setupFormVisibility(formEl: any) {
21
+ const show = 'flex'; // match dees-form's flex layout
22
+ const updateVisibility = async () => {
23
+ const data = await formEl.collectFormData();
24
+ const contentEl = formEl.closest('.content') || formEl.parentElement;
25
+ if (!contentEl) return;
26
+ const hostIpGroup = contentEl.querySelector('.hostIpGroup') as HTMLElement;
27
+ const hostIpDetails = contentEl.querySelector('.hostIpDetails') as HTMLElement;
28
+ const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
29
+ const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
30
+ const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
31
+ if (hostIpGroup) hostIpGroup.style.display = data.forceDestinationSmartproxy ? 'none' : show;
32
+ if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
33
+ if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
34
+ if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
35
+ if (aclGroup) aclGroup.style.display = data.allowAdditionalAcls ? show : 'none';
36
+ };
37
+ formEl.changeSubject.subscribe(() => updateVisibility());
38
+ updateVisibility();
39
+ }
40
+
16
41
  declare global {
17
42
  interface HTMLElementTagNameMap {
18
43
  'ops-view-vpn': OpsViewVpn;
@@ -289,9 +314,18 @@ export class OpsViewVpn extends DeesElement {
289
314
  } else {
290
315
  statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
291
316
  }
317
+ let routingHtml;
318
+ if (client.forceDestinationSmartproxy !== false) {
319
+ routingHtml = html`<span class="statusBadge enabled">SmartProxy</span>`;
320
+ } else if (client.useHostIp) {
321
+ routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#f3e8ff', '#3b0764')}; color: ${cssManager.bdTheme('#7c3aed', '#c084fc')};">Host IP</span>`;
322
+ } else {
323
+ routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">Direct</span>`;
324
+ }
292
325
  return {
293
326
  'Client ID': client.clientId,
294
327
  'Status': statusHtml,
328
+ 'Routing': routingHtml,
295
329
  'VPN IP': client.assignedIp || '-',
296
330
  'Tags': client.serverDefinedClientTags?.length
297
331
  ? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
@@ -307,13 +341,32 @@ export class OpsViewVpn extends DeesElement {
307
341
  type: ['header'],
308
342
  actionFunc: async () => {
309
343
  const { DeesModal } = await import('@design.estate/dees-catalog');
310
- await DeesModal.createAndShow({
344
+ const createModal = await DeesModal.createAndShow({
311
345
  heading: 'Create VPN Client',
312
346
  content: html`
313
347
  <dees-form>
314
348
  <dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
315
349
  <dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
316
350
  <dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
351
+ <dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox>
352
+ <div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
353
+ <dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
354
+ <div class="hostIpDetails" style="display: none; flex-direction: column; gap: 16px;">
355
+ <dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${false}></dees-input-checkbox>
356
+ <div class="staticIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
357
+ <dees-input-text .key=${'staticIp'} .label=${'Static IP'}></dees-input-text>
358
+ </div>
359
+ <dees-input-checkbox .key=${'forceVlan'} .label=${'Force VLAN'} .value=${false}></dees-input-checkbox>
360
+ <div class="vlanIdGroup" style="display: none; flex-direction: column; gap: 16px;">
361
+ <dees-input-text .key=${'vlanId'} .label=${'VLAN ID'}></dees-input-text>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ <dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${false}></dees-input-checkbox>
366
+ <div class="aclGroup" style="display: none; flex-direction: column; gap: 16px;">
367
+ <dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'}></dees-input-text>
368
+ <dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'}></dees-input-text>
369
+ </div>
317
370
  </dees-form>
318
371
  `,
319
372
  menuOptions: [
@@ -333,16 +386,47 @@ export class OpsViewVpn extends DeesElement {
333
386
  const serverDefinedClientTags = data.tags
334
387
  ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
335
388
  : undefined;
389
+
390
+ // Apply conditional logic based on checkbox states
391
+ const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
392
+ const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
393
+ const useDhcp = useHostIp && (data.useDhcp ?? false);
394
+ const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
395
+ const forceVlan = useHostIp && (data.forceVlan ?? false);
396
+ const vlanId = forceVlan && data.vlanId ? parseInt(data.vlanId, 10) : undefined;
397
+
398
+ const allowAcls = data.allowAdditionalAcls ?? false;
399
+ const destinationAllowList = allowAcls && data.destinationAllowList
400
+ ? data.destinationAllowList.split(',').map((s: string) => s.trim()).filter(Boolean)
401
+ : undefined;
402
+ const destinationBlockList = allowAcls && data.destinationBlockList
403
+ ? data.destinationBlockList.split(',').map((s: string) => s.trim()).filter(Boolean)
404
+ : undefined;
405
+
336
406
  await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
337
407
  clientId: data.clientId,
338
408
  description: data.description || undefined,
339
409
  serverDefinedClientTags,
410
+ forceDestinationSmartproxy: forceSmartproxy,
411
+ useHostIp: useHostIp || undefined,
412
+ useDhcp: useDhcp || undefined,
413
+ staticIp,
414
+ forceVlan: forceVlan || undefined,
415
+ vlanId,
416
+ destinationAllowList,
417
+ destinationBlockList,
340
418
  });
341
419
  await modalArg.destroy();
342
420
  },
343
421
  },
344
422
  ],
345
423
  });
424
+ // Setup conditional form visibility after modal renders
425
+ const createForm = createModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
426
+ if (createForm) {
427
+ await createForm.updateComplete;
428
+ setupFormVisibility(createForm);
429
+ }
346
430
  },
347
431
  },
348
432
  {
@@ -396,6 +480,13 @@ export class OpsViewVpn extends DeesElement {
396
480
  ` : ''}
397
481
  <div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
398
482
  <div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div>
483
+ <div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</span></div>
484
+ ${client.useHostIp ? html`
485
+ <div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
486
+ <div class="infoItem"><span class="infoLabel">VLAN</span><span class="infoValue">${client.forceVlan && client.vlanId != null ? `VLAN ${client.vlanId}` : 'No VLAN'}</span></div>
487
+ ` : ''}
488
+ <div class="infoItem"><span class="infoLabel">Allow List</span><span class="infoValue">${client.destinationAllowList?.length ? client.destinationAllowList.join(', ') : 'None'}</span></div>
489
+ <div class="infoItem"><span class="infoLabel">Block List</span><span class="infoValue">${client.destinationBlockList?.length ? client.destinationBlockList.join(', ') : 'None'}</span></div>
399
490
  <div class="infoItem"><span class="infoLabel">Created</span><span class="infoValue">${new Date(client.createdAt).toLocaleString()}</span></div>
400
491
  <div class="infoItem"><span class="infoLabel">Updated</span><span class="infoValue">${new Date(client.updatedAt).toLocaleString()}</span></div>
401
492
  </div>
@@ -553,12 +644,41 @@ export class OpsViewVpn extends DeesElement {
553
644
  const { DeesModal } = await import('@design.estate/dees-catalog');
554
645
  const currentDescription = client.description ?? '';
555
646
  const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
556
- DeesModal.createAndShow({
647
+ const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
648
+ const currentUseHostIp = client.useHostIp ?? false;
649
+ const currentUseDhcp = client.useDhcp ?? false;
650
+ const currentStaticIp = client.staticIp ?? '';
651
+ const currentForceVlan = client.forceVlan ?? false;
652
+ const currentVlanId = client.vlanId != null ? String(client.vlanId) : '';
653
+ const currentAllowList = client.destinationAllowList?.join(', ') ?? '';
654
+ const currentBlockList = client.destinationBlockList?.join(', ') ?? '';
655
+ const currentAllowAcls = (client.destinationAllowList?.length ?? 0) > 0
656
+ || (client.destinationBlockList?.length ?? 0) > 0;
657
+ const editModal = await DeesModal.createAndShow({
557
658
  heading: `Edit: ${client.clientId}`,
558
659
  content: html`
559
660
  <dees-form>
560
661
  <dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
561
662
  <dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text>
663
+ <dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox>
664
+ <div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
665
+ <dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
666
+ <div class="hostIpDetails" style="display: ${currentUseHostIp ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
667
+ <dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${currentUseDhcp}></dees-input-checkbox>
668
+ <div class="staticIpGroup" style="display: ${currentUseDhcp ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
669
+ <dees-input-text .key=${'staticIp'} .label=${'Static IP'} .value=${currentStaticIp}></dees-input-text>
670
+ </div>
671
+ <dees-input-checkbox .key=${'forceVlan'} .label=${'Force VLAN'} .value=${currentForceVlan}></dees-input-checkbox>
672
+ <div class="vlanIdGroup" style="display: ${currentForceVlan ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
673
+ <dees-input-text .key=${'vlanId'} .label=${'VLAN ID'} .value=${currentVlanId}></dees-input-text>
674
+ </div>
675
+ </div>
676
+ </div>
677
+ <dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${currentAllowAcls}></dees-input-checkbox>
678
+ <div class="aclGroup" style="display: ${currentAllowAcls ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
679
+ <dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'} .value=${currentAllowList}></dees-input-text>
680
+ <dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'} .value=${currentBlockList}></dees-input-text>
681
+ </div>
562
682
  </dees-form>
563
683
  `,
564
684
  menuOptions: [
@@ -573,16 +693,47 @@ export class OpsViewVpn extends DeesElement {
573
693
  const serverDefinedClientTags = data.tags
574
694
  ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
575
695
  : [];
696
+
697
+ // Apply conditional logic based on checkbox states
698
+ const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
699
+ const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
700
+ const useDhcp = useHostIp && (data.useDhcp ?? false);
701
+ const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
702
+ const forceVlan = useHostIp && (data.forceVlan ?? false);
703
+ const vlanId = forceVlan && data.vlanId ? parseInt(data.vlanId, 10) : undefined;
704
+
705
+ const allowAcls = data.allowAdditionalAcls ?? false;
706
+ const destinationAllowList = allowAcls && data.destinationAllowList
707
+ ? data.destinationAllowList.split(',').map((s: string) => s.trim()).filter(Boolean)
708
+ : [];
709
+ const destinationBlockList = allowAcls && data.destinationBlockList
710
+ ? data.destinationBlockList.split(',').map((s: string) => s.trim()).filter(Boolean)
711
+ : [];
712
+
576
713
  await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
577
714
  clientId: client.clientId,
578
715
  description: data.description || undefined,
579
716
  serverDefinedClientTags,
717
+ forceDestinationSmartproxy: forceSmartproxy,
718
+ useHostIp: useHostIp || undefined,
719
+ useDhcp: useDhcp || undefined,
720
+ staticIp,
721
+ forceVlan: forceVlan || undefined,
722
+ vlanId,
723
+ destinationAllowList,
724
+ destinationBlockList,
580
725
  });
581
726
  await modalArg.destroy();
582
727
  },
583
728
  },
584
729
  ],
585
730
  });
731
+ // Setup conditional form visibility for edit dialog
732
+ const editForm = editModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
733
+ if (editForm) {
734
+ await editForm.updateComplete;
735
+ setupFormVisibility(editForm);
736
+ }
586
737
  },
587
738
  },
588
739
  {