@serve.zone/dcrouter 13.33.0 → 13.34.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.
Files changed (32) hide show
  1. package/dist_serve/bundle.js +12 -5
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +9 -4
  4. package/dist_ts/config/classes.target-profile-manager.d.ts +16 -3
  5. package/dist_ts/config/classes.target-profile-manager.js +197 -6
  6. package/dist_ts/db/documents/classes.target-profile.doc.d.ts +1 -0
  7. package/dist_ts/db/documents/classes.target-profile.doc.js +8 -2
  8. package/dist_ts/opsserver/handlers/target-profile.handler.js +3 -1
  9. package/dist_ts/opsserver/handlers/vpn.handler.js +3 -1
  10. package/dist_ts/vpn/classes.vpn-manager.d.ts +15 -1
  11. package/dist_ts/vpn/classes.vpn-manager.js +138 -6
  12. package/dist_ts_interfaces/data/target-profile.d.ts +2 -0
  13. package/dist_ts_interfaces/data/vpn.d.ts +4 -0
  14. package/dist_ts_interfaces/requests/target-profiles.d.ts +2 -0
  15. package/dist_ts_web/00_commitinfo_data.js +1 -1
  16. package/dist_ts_web/appstate.d.ts +2 -0
  17. package/dist_ts_web/appstate.js +3 -1
  18. package/dist_ts_web/elements/network/ops-view-targetprofiles.js +10 -1
  19. package/dist_ts_web/elements/network/ops-view-vpn.js +3 -1
  20. package/package.json +1 -1
  21. package/readme.md +13 -0
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/classes.dcrouter.ts +10 -2
  24. package/ts/config/classes.target-profile-manager.ts +229 -5
  25. package/ts/db/documents/classes.target-profile.doc.ts +3 -0
  26. package/ts/opsserver/handlers/target-profile.handler.ts +2 -0
  27. package/ts/opsserver/handlers/vpn.handler.ts +2 -0
  28. package/ts/vpn/classes.vpn-manager.ts +158 -3
  29. package/ts_web/00_commitinfo_data.ts +1 -1
  30. package/ts_web/appstate.ts +4 -0
  31. package/ts_web/elements/network/ops-view-targetprofiles.ts +9 -0
  32. package/ts_web/elements/network/ops-view-vpn.ts +2 -0
@@ -5,6 +5,8 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
5
5
  import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
6
6
  import type { IRoute } from '../../ts_interfaces/data/route-management.js';
7
7
 
8
+ type TIpAllowEntry = string | { ip: string; domains?: string[] };
9
+
8
10
  /**
9
11
  * Manages TargetProfiles (target-side: what can be accessed).
10
12
  * TargetProfiles define what resources a VPN client can reach:
@@ -35,6 +37,7 @@ export class TargetProfileManager {
35
37
  domains?: string[];
36
38
  targets?: ITargetProfileTarget[];
37
39
  routeRefs?: string[];
40
+ allowRoutesByClientSourceIp?: boolean;
38
41
  createdBy: string;
39
42
  }): Promise<string> {
40
43
  // Enforce unique profile names
@@ -55,6 +58,7 @@ export class TargetProfileManager {
55
58
  domains: data.domains,
56
59
  targets: data.targets,
57
60
  routeRefs,
61
+ allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
58
62
  createdAt: now,
59
63
  updatedAt: now,
60
64
  createdBy: data.createdBy,
@@ -88,6 +92,9 @@ export class TargetProfileManager {
88
92
  if (patch.domains !== undefined) profile.domains = patch.domains;
89
93
  if (patch.targets !== undefined) profile.targets = patch.targets;
90
94
  if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
95
+ if (patch.allowRoutesByClientSourceIp !== undefined) {
96
+ profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
97
+ }
91
98
  profile.updatedAt = Date.now();
92
99
 
93
100
  await this.persistProfile(profile);
@@ -208,13 +215,15 @@ export class TargetProfileManager {
208
215
  *
209
216
  * Entries are domain-scoped when a profile matches via specific domains that are
210
217
  * a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
211
- * or when profile domains exactly equal the route's domains.
218
+ * or when profile domains exactly equal the route's domains. Profiles can also opt
219
+ * into source-IP matching against non-vpnOnly route security.
212
220
  */
213
221
  public getMatchingClientIps(
214
222
  route: IDcRouterRouteConfig,
215
223
  routeId: string | undefined,
216
224
  clients: VpnClientDoc[],
217
225
  allRoutes: Map<string, IRoute> = new Map(),
226
+ clientSourceIps: Map<string, string> = new Map(),
218
227
  ): Array<string | { ip: string; domains: string[] }> {
219
228
  const entries: Array<string | { ip: string; domains: string[] }> = [];
220
229
  const routeDomains = this.getRouteDomains(route);
@@ -227,6 +236,7 @@ export class TargetProfileManager {
227
236
  // Collect scoped domains from all matching profiles for this client
228
237
  let fullAccess = false;
229
238
  const scopedDomains = new Set<string>();
239
+ const clientSourceIp = clientSourceIps.get(client.clientId);
230
240
 
231
241
  for (const profileId of client.targetProfileIds) {
232
242
  const profile = this.profiles.get(profileId);
@@ -246,6 +256,16 @@ export class TargetProfileManager {
246
256
  if (matchResult !== 'none') {
247
257
  for (const d of matchResult.domains) scopedDomains.add(d);
248
258
  }
259
+
260
+ if (
261
+ !route.vpnOnly
262
+ && profile.allowRoutesByClientSourceIp === true
263
+ && clientSourceIp
264
+ && this.routeAllowsSourceIp(route, clientSourceIp, routeDomains)
265
+ ) {
266
+ fullAccess = true;
267
+ break;
268
+ }
249
269
  }
250
270
 
251
271
  if (fullAccess) {
@@ -265,6 +285,7 @@ export class TargetProfileManager {
265
285
  public getClientAccessSpec(
266
286
  targetProfileIds: string[],
267
287
  allRoutes: Map<string, IRoute>,
288
+ clientSourceIp?: string,
268
289
  ): { domains: string[]; targetIps: string[] } {
269
290
  const domains = new Set<string>();
270
291
  const targetIps = new Set<string>();
@@ -292,13 +313,20 @@ export class TargetProfileManager {
292
313
  // Route references: scan all routes
293
314
  for (const [routeId, route] of allRoutes) {
294
315
  if (!route.enabled) continue;
295
- if (this.routeMatchesProfile(
296
- route.route as IDcRouterRouteConfig,
316
+ const dcRoute = route.route as IDcRouterRouteConfig;
317
+ const routeDomains = this.getRouteDomains(dcRoute);
318
+ const profileMatchesRoute = this.routeMatchesProfile(
319
+ dcRoute,
297
320
  routeId,
298
321
  profile,
299
322
  routeNameIndex,
300
- )) {
301
- for (const d of this.getRouteDomains(route.route as IDcRouterRouteConfig)) {
323
+ );
324
+ const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
325
+ && clientSourceIp
326
+ && !dcRoute.vpnOnly
327
+ && this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
328
+ if (profileMatchesRoute || sourceIpMatchesRoute) {
329
+ for (const d of routeDomains) {
302
330
  domains.add(d);
303
331
  }
304
332
  }
@@ -422,6 +450,199 @@ export class TargetProfileManager {
422
450
  return false;
423
451
  }
424
452
 
453
+ private routeAllowsSourceIp(
454
+ route: IDcRouterRouteConfig,
455
+ sourceIp: string,
456
+ routeDomains: string[],
457
+ ): boolean {
458
+ const security = (route as any).security;
459
+ const ipAllowList = this.normalizeIpEntries(security?.ipAllowList);
460
+ const ipBlockList = this.normalizeIpEntries(security?.ipBlockList);
461
+
462
+ if (this.ipEntriesMatchSource(ipBlockList, sourceIp, routeDomains)) {
463
+ return false;
464
+ }
465
+
466
+ if (!ipAllowList.length) {
467
+ return true;
468
+ }
469
+
470
+ return this.ipEntriesMatchSource(ipAllowList, sourceIp, routeDomains);
471
+ }
472
+
473
+ private normalizeIpEntries(entries: unknown): TIpAllowEntry[] {
474
+ if (!entries) return [];
475
+ if (Array.isArray(entries)) return entries as TIpAllowEntry[];
476
+ return [entries as TIpAllowEntry];
477
+ }
478
+
479
+ private ipEntriesMatchSource(
480
+ entries: TIpAllowEntry[],
481
+ sourceIp: string,
482
+ routeDomains: string[],
483
+ ): boolean {
484
+ return entries.some((entry) => this.ipEntryMatchesSource(entry, sourceIp, routeDomains));
485
+ }
486
+
487
+ private ipEntryMatchesSource(
488
+ entry: TIpAllowEntry,
489
+ sourceIp: string,
490
+ routeDomains: string[],
491
+ ): boolean {
492
+ const ipPattern = typeof entry === 'string' ? entry : entry.ip;
493
+ if (typeof ipPattern !== 'string') return false;
494
+ if (!this.ipPatternMatchesSource(ipPattern, sourceIp)) {
495
+ return false;
496
+ }
497
+
498
+ if (typeof entry === 'string' || !entry.domains?.length) {
499
+ return true;
500
+ }
501
+
502
+ if (!routeDomains.length) {
503
+ return false;
504
+ }
505
+
506
+ return routeDomains.some((routeDomain) =>
507
+ entry.domains!.some((entryDomain) =>
508
+ this.domainMatchesPattern(routeDomain, entryDomain)
509
+ || this.domainMatchesPattern(entryDomain, routeDomain),
510
+ ),
511
+ );
512
+ }
513
+
514
+ private ipPatternMatchesSource(pattern: string, sourceIp: string): boolean {
515
+ const trimmedPattern = pattern.trim();
516
+ const trimmedSourceIp = sourceIp.trim();
517
+ if (!trimmedPattern || !trimmedSourceIp) return false;
518
+ if (trimmedPattern === '*') return true;
519
+ if (trimmedPattern === trimmedSourceIp) return true;
520
+
521
+ if (trimmedPattern.includes('/')) {
522
+ return this.ipMatchesCidr(trimmedSourceIp, trimmedPattern);
523
+ }
524
+
525
+ if (trimmedPattern.includes('-')) {
526
+ return this.ipMatchesRange(trimmedSourceIp, trimmedPattern);
527
+ }
528
+
529
+ if (trimmedPattern.includes('*')) {
530
+ return this.ipMatchesWildcard(trimmedSourceIp, trimmedPattern);
531
+ }
532
+
533
+ return false;
534
+ }
535
+
536
+ private ipMatchesCidr(sourceIp: string, cidr: string): boolean {
537
+ const [networkIp, prefixString] = cidr.split('/');
538
+ if (!networkIp || !prefixString) return false;
539
+ const source = this.ipToComparable(sourceIp);
540
+ const network = this.ipToComparable(networkIp);
541
+ const prefix = Number(prefixString);
542
+ if (!source || !network || source.version !== network.version) return false;
543
+
544
+ const bitCount = source.version === 4 ? 32 : 128;
545
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bitCount) return false;
546
+ if (prefix === 0) return true;
547
+
548
+ const shift = BigInt(bitCount - prefix);
549
+ return (source.value >> shift) === (network.value >> shift);
550
+ }
551
+
552
+ private ipMatchesRange(sourceIp: string, range: string): boolean {
553
+ const [startIp, endIp] = range.split('-').map((part) => part.trim());
554
+ if (!startIp || !endIp) return false;
555
+ const source = this.ipToComparable(sourceIp);
556
+ const start = this.ipToComparable(startIp);
557
+ const end = this.ipToComparable(endIp);
558
+ if (!source || !start || !end) return false;
559
+ if (source.version !== start.version || source.version !== end.version) return false;
560
+ return source.value >= start.value && source.value <= end.value;
561
+ }
562
+
563
+ private ipMatchesWildcard(sourceIp: string, pattern: string): boolean {
564
+ const sourceParts = sourceIp.split('.');
565
+ const patternParts = pattern.split('.');
566
+ if (sourceParts.length !== 4 || patternParts.length !== 4) return false;
567
+
568
+ return patternParts.every((patternPart, index) => {
569
+ if (patternPart === '*') return true;
570
+ return patternPart === sourceParts[index];
571
+ });
572
+ }
573
+
574
+ private ipToComparable(ip: string): { version: 4 | 6; value: bigint } | undefined {
575
+ const normalizedIp = this.normalizeIpLiteral(ip);
576
+ const ipVersion = plugins.net.isIP(normalizedIp);
577
+ if (ipVersion === 4) {
578
+ const parts = normalizedIp.split('.').map((part) => Number(part));
579
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
580
+ return undefined;
581
+ }
582
+ return {
583
+ version: 4,
584
+ value: parts.reduce((value, part) => (value << 8n) + BigInt(part), 0n),
585
+ };
586
+ }
587
+
588
+ if (ipVersion === 6) {
589
+ const parts = this.expandIpv6(normalizedIp);
590
+ if (!parts) return undefined;
591
+ return {
592
+ version: 6,
593
+ value: parts.reduce((value, part) => (value << 16n) + BigInt(part), 0n),
594
+ };
595
+ }
596
+
597
+ return undefined;
598
+ }
599
+
600
+ private normalizeIpLiteral(ip: string): string {
601
+ const trimmed = ip.trim().replace(/^\[|\]$/g, '');
602
+ const zoneIndex = trimmed.indexOf('%');
603
+ const withoutZone = zoneIndex === -1 ? trimmed : trimmed.slice(0, zoneIndex);
604
+ const ipv4MappedPrefix = '::ffff:';
605
+ if (withoutZone.toLowerCase().startsWith(ipv4MappedPrefix)) {
606
+ const mappedIpv4 = withoutZone.slice(ipv4MappedPrefix.length);
607
+ if (plugins.net.isIP(mappedIpv4) === 4) return mappedIpv4;
608
+ }
609
+ return withoutZone;
610
+ }
611
+
612
+ private expandIpv6(ip: string): number[] | undefined {
613
+ let normalizedIp = ip.toLowerCase();
614
+ if (normalizedIp.includes('.')) {
615
+ const lastColonIndex = normalizedIp.lastIndexOf(':');
616
+ const ipv4Part = normalizedIp.slice(lastColonIndex + 1);
617
+ const ipv4Comparable = this.ipToComparable(ipv4Part);
618
+ if (!ipv4Comparable || ipv4Comparable.version !== 4) return undefined;
619
+ const high = Number((ipv4Comparable.value >> 16n) & 0xffffn).toString(16);
620
+ const low = Number(ipv4Comparable.value & 0xffffn).toString(16);
621
+ normalizedIp = `${normalizedIp.slice(0, lastColonIndex)}:${high}:${low}`;
622
+ }
623
+
624
+ const doubleColonParts = normalizedIp.split('::');
625
+ if (doubleColonParts.length > 2) return undefined;
626
+
627
+ const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : [];
628
+ const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : [];
629
+ const missingCount = 8 - head.length - tail.length;
630
+ if (missingCount < 0 || (doubleColonParts.length === 1 && missingCount !== 0)) return undefined;
631
+
632
+ const parts = [
633
+ ...head,
634
+ ...Array(missingCount).fill('0'),
635
+ ...tail,
636
+ ];
637
+ if (parts.length !== 8) return undefined;
638
+
639
+ const numbers = parts.map((part) => Number.parseInt(part || '0', 16));
640
+ if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
641
+ return undefined;
642
+ }
643
+ return numbers;
644
+ }
645
+
425
646
  private getRouteDomains(route: IDcRouterRouteConfig): string[] {
426
647
  const domains = (route.match as any)?.domains;
427
648
  if (!domains) return [];
@@ -503,6 +724,7 @@ export class TargetProfileManager {
503
724
  domains: doc.domains,
504
725
  targets: doc.targets,
505
726
  routeRefs: doc.routeRefs,
727
+ allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
506
728
  createdAt: doc.createdAt,
507
729
  updatedAt: doc.updatedAt,
508
730
  createdBy: doc.createdBy,
@@ -522,6 +744,7 @@ export class TargetProfileManager {
522
744
  existingDoc.domains = profile.domains;
523
745
  existingDoc.targets = profile.targets;
524
746
  existingDoc.routeRefs = profile.routeRefs;
747
+ existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
525
748
  existingDoc.updatedAt = profile.updatedAt;
526
749
  await existingDoc.save();
527
750
  } else {
@@ -532,6 +755,7 @@ export class TargetProfileManager {
532
755
  doc.domains = profile.domains;
533
756
  doc.targets = profile.targets;
534
757
  doc.routeRefs = profile.routeRefs;
758
+ doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
535
759
  doc.createdAt = profile.createdAt;
536
760
  doc.updatedAt = profile.updatedAt;
537
761
  doc.createdBy = profile.createdBy;
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
25
25
  @plugins.smartdata.svDb()
26
26
  public routeRefs?: string[];
27
27
 
28
+ @plugins.smartdata.svDb()
29
+ public allowRoutesByClientSourceIp?: boolean;
30
+
28
31
  @plugins.smartdata.svDb()
29
32
  public createdAt!: number;
30
33
 
@@ -69,6 +69,7 @@ export class TargetProfileHandler {
69
69
  domains: dataArg.domains,
70
70
  targets: dataArg.targets,
71
71
  routeRefs: dataArg.routeRefs,
72
+ allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
72
73
  createdBy: userId,
73
74
  });
74
75
  await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -94,6 +95,7 @@ export class TargetProfileHandler {
94
95
  domains: dataArg.domains,
95
96
  targets: dataArg.targets,
96
97
  routeRefs: dataArg.routeRefs,
98
+ allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
97
99
  });
98
100
  // Re-apply routes and refresh VPN client security to update access
99
101
  await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -102,6 +102,8 @@ export class VpnHandler {
102
102
  bytesSent: c.bytesSent,
103
103
  bytesReceived: c.bytesReceived,
104
104
  transport: c.transportType,
105
+ remoteAddr: c.remoteAddr,
106
+ sourceIp: manager.getClientSourceIp(c.registeredClientId || c.clientId),
105
107
  })),
106
108
  };
107
109
  },
@@ -19,6 +19,10 @@ export interface IVpnManagerConfig {
19
19
  }>;
20
20
  /** Called when clients are created/deleted/toggled — triggers route re-application */
21
21
  onClientChanged?: () => void;
22
+ /** Called when a live VPN client's real source IP changes. */
23
+ onClientSourceIpsChanged?: () => void;
24
+ /** Poll interval for live VPN client real source IP updates. Default: 10 seconds. */
25
+ clientSourceIpPollIntervalMs?: number;
22
26
  /** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
23
27
  destinationPolicy?: {
24
28
  default: 'forceTarget' | 'block' | 'allow';
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
29
33
  /** Compute per-client AllowedIPs based on the client's target profile IDs.
30
34
  * Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
31
35
  * When not set, defaults to [subnet]. */
32
- getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
36
+ getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise<string[]>;
33
37
  /** Resolve per-client destination allow-list IPs from target profile IDs.
34
38
  * Returns IP strings that should bypass forceTarget and go direct to the real destination. */
35
39
  getClientDirectTargets?: (targetProfileIds: string[]) => string[];
@@ -57,6 +61,9 @@ export class VpnManager {
57
61
  private serverKeys?: VpnServerKeysDoc;
58
62
  private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
59
63
  private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
64
+ private clientSourceIps = new Map<string, string>();
65
+ private clientSourceIpPollTimer?: ReturnType<typeof setInterval>;
66
+ private clientSourceIpRefreshInFlight = false;
60
67
 
61
68
  constructor(config: IVpnManagerConfig) {
62
69
  this.config = config;
@@ -173,6 +180,9 @@ export class VpnManager {
173
180
  }
174
181
  }
175
182
 
183
+ await this.refreshClientSourceIps(false);
184
+ this.startClientSourceIpPolling();
185
+
176
186
  logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
177
187
  }
178
188
 
@@ -180,6 +190,7 @@ export class VpnManager {
180
190
  * Stop the VPN server.
181
191
  */
182
192
  public async stop(): Promise<void> {
193
+ this.stopClientSourceIpPolling();
183
194
  if (this.vpnServer) {
184
195
  try {
185
196
  await this.vpnServer.stopServer();
@@ -189,6 +200,11 @@ export class VpnManager {
189
200
  await this.vpnServer.stop();
190
201
  this.vpnServer = undefined;
191
202
  }
203
+ const hadClientSourceIps = this.clientSourceIps.size > 0;
204
+ this.clientSourceIps.clear();
205
+ if (hadClientSourceIps) {
206
+ this.config.onClientSourceIpsChanged?.();
207
+ }
192
208
  this.resolvedForwardingMode = undefined;
193
209
  logger.log('info', 'VPN server stopped');
194
210
  }
@@ -246,6 +262,7 @@ export class VpnManager {
246
262
  bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
247
263
  bundle.wireguardConfig,
248
264
  doc.targetProfileIds || [],
265
+ doc.clientId,
249
266
  );
250
267
 
251
268
  // Persist client entry (including WG private key for export/QR)
@@ -287,6 +304,7 @@ export class VpnManager {
287
304
  await this.vpnServer.removeClient(clientId);
288
305
  const doc = this.clients.get(clientId);
289
306
  this.clients.delete(clientId);
307
+ this.clientSourceIps.delete(clientId);
290
308
  if (doc) {
291
309
  await doc.delete();
292
310
  }
@@ -328,6 +346,7 @@ export class VpnManager {
328
346
  client.updatedAt = Date.now();
329
347
  await this.persistClient(client);
330
348
  }
349
+ this.clientSourceIps.delete(clientId);
331
350
  this.config.onClientChanged?.();
332
351
  }
333
352
 
@@ -380,6 +399,7 @@ export class VpnManager {
380
399
  bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
381
400
  bundle.wireguardConfig,
382
401
  client?.targetProfileIds || [],
402
+ clientId,
383
403
  );
384
404
 
385
405
  // Update persisted entry with new keys (including private key for export/QR)
@@ -413,7 +433,11 @@ export class VpnManager {
413
433
  );
414
434
  }
415
435
 
416
- config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []);
436
+ config = await this.rewriteWireGuardAllowedIPs(
437
+ config,
438
+ persisted?.targetProfileIds || [],
439
+ clientId,
440
+ );
417
441
  }
418
442
 
419
443
  return config;
@@ -445,6 +469,107 @@ export class VpnManager {
445
469
  return this.vpnServer.listClients();
446
470
  }
447
471
 
472
+ public getClientSourceIp(clientId: string): string | undefined {
473
+ return this.clientSourceIps.get(clientId);
474
+ }
475
+
476
+ public getClientSourceIpMap(): Map<string, string> {
477
+ return new Map(this.clientSourceIps);
478
+ }
479
+
480
+ public async refreshClientSourceIps(notifyOnChange = true): Promise<boolean> {
481
+ if (!this.vpnServer || this.clientSourceIpRefreshInFlight) {
482
+ return false;
483
+ }
484
+
485
+ this.clientSourceIpRefreshInFlight = true;
486
+ try {
487
+ const connectedClients = await this.vpnServer.listClients();
488
+ const nextSourceIps = new Map<string, string>();
489
+ const wireguardClientIds = new Set<string>();
490
+
491
+ for (const connectedClient of connectedClients) {
492
+ const clientId = connectedClient.registeredClientId || connectedClient.clientId;
493
+ if (!clientId) continue;
494
+ if (connectedClient.transportType === 'wireguard') {
495
+ wireguardClientIds.add(clientId);
496
+ }
497
+
498
+ const sourceIp = VpnManager.normalizeRemoteAddress(connectedClient.remoteAddr);
499
+ if (sourceIp) {
500
+ nextSourceIps.set(clientId, sourceIp);
501
+ }
502
+ }
503
+
504
+ if (wireguardClientIds.size > 0 && typeof (this.vpnServer as any).listWgPeers === 'function') {
505
+ try {
506
+ const wgPeers = await this.vpnServer.listWgPeers();
507
+ const endpointByPublicKey = new Map<string, string>();
508
+ for (const peer of wgPeers) {
509
+ const endpointIp = VpnManager.normalizeRemoteAddress(peer.endpoint);
510
+ if (peer.publicKey && endpointIp) {
511
+ endpointByPublicKey.set(peer.publicKey, endpointIp);
512
+ }
513
+ }
514
+
515
+ for (const client of this.clients.values()) {
516
+ if (nextSourceIps.has(client.clientId)) continue;
517
+ if (!wireguardClientIds.has(client.clientId)) continue;
518
+ if (!client.wgPublicKey) continue;
519
+ const endpointIp = endpointByPublicKey.get(client.wgPublicKey);
520
+ if (endpointIp) {
521
+ nextSourceIps.set(client.clientId, endpointIp);
522
+ }
523
+ }
524
+ } catch (err) {
525
+ logger.log('warn', `VPN: Failed to refresh WireGuard peer endpoints: ${(err as Error).message}`);
526
+ }
527
+ }
528
+
529
+ if (this.sameSourceIpMap(this.clientSourceIps, nextSourceIps)) {
530
+ return false;
531
+ }
532
+
533
+ this.clientSourceIps = nextSourceIps;
534
+ if (notifyOnChange) {
535
+ this.config.onClientSourceIpsChanged?.();
536
+ }
537
+ return true;
538
+ } catch (err) {
539
+ logger.log('warn', `VPN: Failed to refresh client source IPs: ${(err as Error).message}`);
540
+ return false;
541
+ } finally {
542
+ this.clientSourceIpRefreshInFlight = false;
543
+ }
544
+ }
545
+
546
+ public static normalizeRemoteAddress(remoteAddress?: string): string | undefined {
547
+ const remoteAddressString = remoteAddress?.trim();
548
+ if (!remoteAddressString) return undefined;
549
+
550
+ if (remoteAddressString.startsWith('[')) {
551
+ const closingBracketIndex = remoteAddressString.indexOf(']');
552
+ if (closingBracketIndex > 0) {
553
+ const bracketedIp = remoteAddressString.slice(1, closingBracketIndex);
554
+ return plugins.net.isIP(bracketedIp) ? bracketedIp : undefined;
555
+ }
556
+ }
557
+
558
+ if (plugins.net.isIP(remoteAddressString)) {
559
+ return remoteAddressString;
560
+ }
561
+
562
+ const lastColonIndex = remoteAddressString.lastIndexOf(':');
563
+ if (lastColonIndex > -1 && remoteAddressString.indexOf(':') === lastColonIndex) {
564
+ const host = remoteAddressString.slice(0, lastColonIndex);
565
+ if (plugins.net.isIP(host)) {
566
+ return host;
567
+ }
568
+ }
569
+
570
+ return undefined;
571
+ }
572
+
448
573
  /**
449
574
  * Get telemetry for a specific client.
450
575
  */
@@ -533,10 +658,15 @@ export class VpnManager {
533
658
  private async rewriteWireGuardAllowedIPs(
534
659
  wireguardConfig: string,
535
660
  targetProfileIds: string[],
661
+ clientId?: string,
536
662
  ): Promise<string> {
537
663
  if (!this.config.getClientAllowedIPs) return wireguardConfig;
538
664
 
539
- const allowedIPs = await this.config.getClientAllowedIPs(targetProfileIds);
665
+ const allowedIPs = await this.config.getClientAllowedIPs(
666
+ targetProfileIds,
667
+ clientId,
668
+ clientId ? this.getClientSourceIp(clientId) : undefined,
669
+ );
540
670
  const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
541
671
  const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
542
672
 
@@ -587,6 +717,31 @@ export class VpnManager {
587
717
  }
588
718
  }
589
719
 
720
+ private startClientSourceIpPolling(): void {
721
+ this.stopClientSourceIpPolling();
722
+ const pollIntervalMs = Math.max(1000, this.config.clientSourceIpPollIntervalMs ?? 10_000);
723
+ this.clientSourceIpPollTimer = setInterval(() => {
724
+ void this.refreshClientSourceIps().catch((err) => {
725
+ logger.log('warn', `VPN: Client source IP polling failed: ${err?.message || err}`);
726
+ });
727
+ }, pollIntervalMs);
728
+ this.clientSourceIpPollTimer.unref?.();
729
+ }
730
+
731
+ private stopClientSourceIpPolling(): void {
732
+ if (!this.clientSourceIpPollTimer) return;
733
+ clearInterval(this.clientSourceIpPollTimer);
734
+ this.clientSourceIpPollTimer = undefined;
735
+ }
736
+
737
+ private sameSourceIpMap(left: Map<string, string>, right: Map<string, string>): boolean {
738
+ if (left.size !== right.size) return false;
739
+ for (const [clientId, sourceIp] of left) {
740
+ if (right.get(clientId) !== sourceIp) return false;
741
+ }
742
+ return true;
743
+ }
744
+
590
745
  private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
591
746
  return this.resolvedForwardingMode
592
747
  ?? this.forwardingModeOverride
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.33.0',
6
+ version: '13.34.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -1569,6 +1569,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
1569
1569
  domains?: string[];
1570
1570
  targets?: Array<{ ip: string; port: number }>;
1571
1571
  routeRefs?: string[];
1572
+ allowRoutesByClientSourceIp?: boolean;
1572
1573
  }>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
1573
1574
  const context = getActionContext();
1574
1575
  try {
@@ -1582,6 +1583,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
1582
1583
  domains: dataArg.domains,
1583
1584
  targets: dataArg.targets,
1584
1585
  routeRefs: dataArg.routeRefs,
1586
+ allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
1585
1587
  });
1586
1588
  if (!response.success) {
1587
1589
  return {
@@ -1605,6 +1607,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
1605
1607
  domains?: string[];
1606
1608
  targets?: Array<{ ip: string; port: number }>;
1607
1609
  routeRefs?: string[];
1610
+ allowRoutesByClientSourceIp?: boolean;
1608
1611
  }>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
1609
1612
  const context = getActionContext();
1610
1613
  try {
@@ -1619,6 +1622,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
1619
1622
  domains: dataArg.domains,
1620
1623
  targets: dataArg.targets,
1621
1624
  routeRefs: dataArg.routeRefs,
1625
+ allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
1622
1626
  });
1623
1627
  if (!response.success) {
1624
1628
  return {