@serve.zone/dcrouter 13.0.6 → 13.0.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.
@@ -134,6 +134,27 @@ export class TargetProfileManager {
134
134
  .map((c) => ({ clientId: c.clientId, description: c.description }));
135
135
  }
136
136
 
137
+ // =========================================================================
138
+ // Direct target IPs (bypass SmartProxy)
139
+ // =========================================================================
140
+
141
+ /**
142
+ * For a set of target profile IDs, collect all explicit target host IPs.
143
+ * These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
144
+ * connect to them directly through the tunnel.
145
+ */
146
+ public getDirectTargetIps(targetProfileIds: string[]): string[] {
147
+ const ips = new Set<string>();
148
+ for (const profileId of targetProfileIds) {
149
+ const profile = this.profiles.get(profileId);
150
+ if (!profile?.targets?.length) continue;
151
+ for (const t of profile.targets) {
152
+ ips.add(t.host);
153
+ }
154
+ }
155
+ return [...ips];
156
+ }
157
+
137
158
  // =========================================================================
138
159
  // Core matching: route → client IPs
139
160
  // =========================================================================
@@ -110,8 +110,9 @@ export class TargetProfileHandler {
110
110
  targets: dataArg.targets,
111
111
  routeRefs: dataArg.routeRefs,
112
112
  });
113
- // Re-apply routes to update VPN access
113
+ // Re-apply routes and refresh VPN client security to update access
114
114
  this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
115
+ this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
115
116
  return { success: true };
116
117
  },
117
118
  ),
@@ -129,8 +130,9 @@ export class TargetProfileHandler {
129
130
  }
130
131
  const result = await manager.deleteProfile(dataArg.id, dataArg.force);
131
132
  if (result.success) {
132
- // Re-apply routes to update VPN access
133
+ // Re-apply routes and refresh VPN client security to update access
133
134
  this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
135
+ this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
134
136
  }
135
137
  return result;
136
138
  },
@@ -30,6 +30,9 @@ 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?: (targetProfileIds: string[]) => Promise<string[]>;
33
+ /** Resolve per-client destination allow-list IPs from target profile IDs.
34
+ * Returns IP strings that should bypass forceTarget and go direct to the real destination. */
35
+ getClientDirectTargets?: (targetProfileIds: string[]) => string[];
33
36
  /** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
34
37
  * or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
35
38
  forwardingMode?: 'socket' | 'bridge' | 'hybrid';
@@ -477,18 +480,28 @@ export class VpnManager {
477
480
  const security: plugins.smartvpn.IClientSecurity = {};
478
481
  const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
479
482
 
483
+ // Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
484
+ const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
485
+
486
+ // Merge with per-client explicit allow list
487
+ const mergedAllowList = [
488
+ ...(client.destinationAllowList || []),
489
+ ...profileDirectTargets,
490
+ ];
491
+
480
492
  if (!forceSmartproxy) {
481
493
  // Client traffic goes directly — not forced to SmartProxy
482
494
  security.destinationPolicy = {
483
495
  default: 'allow' as const,
484
496
  blockList: client.destinationBlockList,
485
497
  };
486
- } else if (client.destinationAllowList?.length || client.destinationBlockList?.length) {
487
- // Client is forced to SmartProxy, but with per-client allow/block overrides
498
+ } else if (mergedAllowList.length || client.destinationBlockList?.length) {
499
+ // Client is forced to SmartProxy, but with allow/block overrides
500
+ // (includes TargetProfile direct targets that bypass SmartProxy)
488
501
  security.destinationPolicy = {
489
502
  default: 'forceTarget' as const,
490
503
  target: '127.0.0.1',
491
- allowList: client.destinationAllowList,
504
+ allowList: mergedAllowList.length ? mergedAllowList : undefined,
492
505
  blockList: client.destinationBlockList,
493
506
  };
494
507
  }
@@ -497,6 +510,20 @@ export class VpnManager {
497
510
  return security;
498
511
  }
499
512
 
513
+ /**
514
+ * Refresh all client security policies against the running daemon.
515
+ * Call this when TargetProfiles change so destination allow-lists stay in sync.
516
+ */
517
+ public async refreshAllClientSecurity(): Promise<void> {
518
+ if (!this.vpnServer) return;
519
+ for (const client of this.clients.values()) {
520
+ const security = this.buildClientSecurity(client);
521
+ if (security.destinationPolicy) {
522
+ await this.vpnServer.updateClient(client.clientId, { security });
523
+ }
524
+ }
525
+ }
526
+
500
527
  // ── Private helpers ────────────────────────────────────────────────────
501
528
 
502
529
  private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.0.6',
6
+ version: '13.0.8',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -156,8 +156,16 @@ export class OpsViewTargetProfiles extends DeesElement {
156
156
  .map((mr) => ({ viewKey: mr.route.name! }));
157
157
  }
158
158
 
159
+ private async ensureRoutesLoaded() {
160
+ const routeState = appstate.routeManagementStatePart.getState();
161
+ if (!routeState?.mergedRoutes?.length) {
162
+ await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
163
+ }
164
+ }
165
+
159
166
  private async showCreateProfileDialog() {
160
167
  const { DeesModal } = await import('@design.estate/dees-catalog');
168
+ await this.ensureRoutesLoaded();
161
169
  const routeCandidates = this.getRouteCandidates();
162
170
 
163
171
  DeesModal.createAndShow({
@@ -216,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
216
224
  const currentRouteRefs = profile.routeRefs || [];
217
225
 
218
226
  const { DeesModal } = await import('@design.estate/dees-catalog');
227
+ await this.ensureRoutesLoaded();
219
228
  const routeCandidates = this.getRouteCandidates();
220
229
 
221
230
  DeesModal.createAndShow({
@@ -60,6 +60,8 @@ export class OpsViewVpn extends DeesElement {
60
60
  async connectedCallback() {
61
61
  await super.connectedCallback();
62
62
  await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
63
+ // Ensure target profiles are loaded for autocomplete candidates
64
+ await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
63
65
  }
64
66
 
65
67
  public static styles = [
@@ -328,7 +330,11 @@ export class OpsViewVpn extends DeesElement {
328
330
  'Routing': routingHtml,
329
331
  'VPN IP': client.assignedIp || '-',
330
332
  'Target Profiles': client.targetProfileIds?.length
331
- ? html`${client.targetProfileIds.map(t => html`<span class="tagBadge">${t}</span>`)}`
333
+ ? html`${client.targetProfileIds.map(id => {
334
+ const profileState = appstate.targetProfilesStatePart.getState();
335
+ const profile = profileState?.profiles.find(p => p.id === id);
336
+ return html`<span class="tagBadge">${profile?.name || id}</span>`;
337
+ })}`
332
338
  : '-',
333
339
  'Description': client.description || '-',
334
340
  'Created': new Date(client.createdAt).toLocaleDateString(),
@@ -341,13 +347,14 @@ export class OpsViewVpn extends DeesElement {
341
347
  type: ['header'],
342
348
  actionFunc: async () => {
343
349
  const { DeesModal } = await import('@design.estate/dees-catalog');
350
+ const profileCandidates = this.getTargetProfileCandidates();
344
351
  const createModal = await DeesModal.createAndShow({
345
352
  heading: 'Create VPN Client',
346
353
  content: html`
347
354
  <dees-form>
348
355
  <dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
349
356
  <dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
350
- <dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'}></dees-input-text>
357
+ <dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false}></dees-input-list>
351
358
  <dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox>
352
359
  <div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
353
360
  <dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
@@ -383,9 +390,9 @@ export class OpsViewVpn extends DeesElement {
383
390
  if (!form) return;
384
391
  const data = await form.collectFormData();
385
392
  if (!data.clientId) return;
386
- const targetProfileIds = data.targetProfileIds
387
- ? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
388
- : undefined;
393
+ const targetProfileIds = this.resolveProfileNamesToIds(
394
+ Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
395
+ );
389
396
 
390
397
  // Apply conditional logic based on checkbox states
391
398
  const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
@@ -479,7 +486,7 @@ export class OpsViewVpn extends DeesElement {
479
486
  <div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
480
487
  ` : ''}
481
488
  <div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
482
- <div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${client.targetProfileIds?.join(', ') || '-'}</span></div>
489
+ <div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
483
490
  <div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</span></div>
484
491
  ${client.useHostIp ? html`
485
492
  <div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
@@ -643,7 +650,8 @@ export class OpsViewVpn extends DeesElement {
643
650
  const client = actionData.item as interfaces.data.IVpnClient;
644
651
  const { DeesModal } = await import('@design.estate/dees-catalog');
645
652
  const currentDescription = client.description ?? '';
646
- const currentTargetProfileIds = client.targetProfileIds?.join(', ') ?? '';
653
+ const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
654
+ const profileCandidates = this.getTargetProfileCandidates();
647
655
  const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
648
656
  const currentUseHostIp = client.useHostIp ?? false;
649
657
  const currentUseDhcp = client.useDhcp ?? false;
@@ -659,7 +667,7 @@ export class OpsViewVpn extends DeesElement {
659
667
  content: html`
660
668
  <dees-form>
661
669
  <dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
662
- <dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'} .value=${currentTargetProfileIds}></dees-input-text>
670
+ <dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false} .value=${currentTargetProfileNames}></dees-input-list>
663
671
  <dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox>
664
672
  <div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
665
673
  <dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
@@ -690,9 +698,9 @@ export class OpsViewVpn extends DeesElement {
690
698
  const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
691
699
  if (!form) return;
692
700
  const data = await form.collectFormData();
693
- const targetProfileIds = data.targetProfileIds
694
- ? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
695
- : [];
701
+ const targetProfileIds = this.resolveProfileNamesToIds(
702
+ Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
703
+ );
696
704
 
697
705
  // Apply conditional logic based on checkbox states
698
706
  const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
@@ -805,4 +813,43 @@ export class OpsViewVpn extends DeesElement {
805
813
  </div>
806
814
  `;
807
815
  }
816
+
817
+ /**
818
+ * Build autocomplete candidates from loaded target profiles.
819
+ * viewKey = profile name (displayed), payload = { id } (carried for resolution).
820
+ */
821
+ private getTargetProfileCandidates() {
822
+ const profileState = appstate.targetProfilesStatePart.getState();
823
+ const profiles = profileState?.profiles || [];
824
+ return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
825
+ }
826
+
827
+ /**
828
+ * Convert profile IDs to profile names (for populating edit form values).
829
+ */
830
+ private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
831
+ if (!ids?.length) return undefined;
832
+ const profileState = appstate.targetProfilesStatePart.getState();
833
+ const profiles = profileState?.profiles || [];
834
+ return ids.map((id) => {
835
+ const profile = profiles.find((p) => p.id === id);
836
+ return profile?.name || id;
837
+ });
838
+ }
839
+
840
+ /**
841
+ * Convert profile names back to IDs (for saving form data).
842
+ * Uses the dees-input-list candidates' payload when available.
843
+ */
844
+ private resolveProfileNamesToIds(names: string[]): string[] | undefined {
845
+ if (!names.length) return undefined;
846
+ const profileState = appstate.targetProfilesStatePart.getState();
847
+ const profiles = profileState?.profiles || [];
848
+ return names
849
+ .map((name) => {
850
+ const profile = profiles.find((p) => p.name === name);
851
+ return profile?.id;
852
+ })
853
+ .filter((id): id is string => !!id);
854
+ }
808
855
  }