@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.
- package/dist_serve/bundle.js +12 -5
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +9 -4
- package/dist_ts/config/classes.target-profile-manager.d.ts +16 -3
- package/dist_ts/config/classes.target-profile-manager.js +197 -6
- package/dist_ts/db/documents/classes.target-profile.doc.d.ts +1 -0
- package/dist_ts/db/documents/classes.target-profile.doc.js +8 -2
- package/dist_ts/opsserver/handlers/target-profile.handler.js +3 -1
- package/dist_ts/opsserver/handlers/vpn.handler.js +3 -1
- package/dist_ts/vpn/classes.vpn-manager.d.ts +15 -1
- package/dist_ts/vpn/classes.vpn-manager.js +138 -6
- package/dist_ts_interfaces/data/target-profile.d.ts +2 -0
- package/dist_ts_interfaces/data/vpn.d.ts +4 -0
- package/dist_ts_interfaces/requests/target-profiles.d.ts +2 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.d.ts +2 -0
- package/dist_ts_web/appstate.js +3 -1
- package/dist_ts_web/elements/network/ops-view-targetprofiles.js +10 -1
- package/dist_ts_web/elements/network/ops-view-vpn.js +3 -1
- package/package.json +1 -1
- package/readme.md +13 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +10 -2
- package/ts/config/classes.target-profile-manager.ts +229 -5
- package/ts/db/documents/classes.target-profile.doc.ts +3 -0
- package/ts/opsserver/handlers/target-profile.handler.ts +2 -0
- package/ts/opsserver/handlers/vpn.handler.ts +2 -0
- package/ts/vpn/classes.vpn-manager.ts +158 -3
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +4 -0
- package/ts_web/elements/network/ops-view-targetprofiles.ts +9 -0
- 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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -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(
|
|
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(
|
|
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
|
package/ts_web/appstate.ts
CHANGED
|
@@ -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 {
|