@serve.zone/dcrouter 13.32.1 → 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 +597 -590
- 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/monitoring/classes.metricsmanager.js +5 -2
- package/dist_ts/opsserver/handlers/security.handler.js +9 -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/security/classes.security-policy-manager.d.ts +15 -1
- package/dist_ts/security/classes.security-policy-manager.js +108 -9
- 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/security-policy.d.ts +2 -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 +77 -47
- 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 +2 -2
- 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/monitoring/classes.metricsmanager.ts +4 -1
- package/ts/opsserver/handlers/security.handler.ts +8 -1
- package/ts/opsserver/handlers/target-profile.handler.ts +2 -0
- package/ts/opsserver/handlers/vpn.handler.ts +2 -0
- package/ts/security/classes.security-policy-manager.ts +115 -7
- package/ts/vpn/classes.vpn-manager.ts +158 -3
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +86 -48
- 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
|
|
|
@@ -725,7 +725,10 @@ export class MetricsManager {
|
|
|
725
725
|
.slice(0, 10)
|
|
726
726
|
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
|
727
727
|
|
|
728
|
-
|
|
728
|
+
this.dcRouter.securityPolicyManager?.queueObservedIps([
|
|
729
|
+
...topIPs.map((item) => item.ip),
|
|
730
|
+
...topIPsByBandwidth.map((item) => item.ip),
|
|
731
|
+
]);
|
|
729
732
|
|
|
730
733
|
// Build domain activity using per-IP domain request counts from Rust engine
|
|
731
734
|
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
|
@@ -180,7 +180,14 @@ export class SecurityHandler {
|
|
|
180
180
|
async (dataArg) => {
|
|
181
181
|
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
|
182
182
|
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
|
183
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
records: manager
|
|
185
|
+
? await manager.listIpIntelligence({
|
|
186
|
+
ipAddresses: dataArg.ipAddresses,
|
|
187
|
+
limit: dataArg.limit,
|
|
188
|
+
})
|
|
189
|
+
: [],
|
|
190
|
+
};
|
|
184
191
|
},
|
|
185
192
|
),
|
|
186
193
|
);
|
|
@@ -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,12 +19,24 @@ export interface IRemoteIngressFirewallSnapshot {
|
|
|
19
19
|
blockedIps: string[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const OBSERVED_IP_QUEUE_LIMIT = 512;
|
|
23
|
+
const OBSERVED_IP_BATCH_LIMIT = 20;
|
|
24
|
+
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
|
|
25
|
+
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
|
|
26
|
+
|
|
22
27
|
export class SecurityPolicyManager {
|
|
23
28
|
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
|
24
29
|
cacheTtl: 24 * 60 * 60 * 1000,
|
|
30
|
+
ipIntelligenceTimeout: 5_000,
|
|
25
31
|
});
|
|
26
32
|
private readonly intelligenceRefreshMs: number;
|
|
27
|
-
private readonly inFlightObservations = new
|
|
33
|
+
private readonly inFlightObservations = new Map<string, Promise<void>>();
|
|
34
|
+
private readonly queuedObservations = new Set<string>();
|
|
35
|
+
private readonly observationQueue: string[] = [];
|
|
36
|
+
private readonly lastQueuedAt = new Map<string, number>();
|
|
37
|
+
private activeQueuedObservations = 0;
|
|
38
|
+
private queueDrainScheduled = false;
|
|
39
|
+
private isStopping = false;
|
|
28
40
|
private readonly onPolicyChanged?: () => void | Promise<void>;
|
|
29
41
|
|
|
30
42
|
constructor(options: ISecurityPolicyManagerOptions = {}) {
|
|
@@ -37,6 +49,9 @@ export class SecurityPolicyManager {
|
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
public async stop(): Promise<void> {
|
|
52
|
+
this.isStopping = true;
|
|
53
|
+
this.observationQueue.length = 0;
|
|
54
|
+
this.queuedObservations.clear();
|
|
40
55
|
await this.smartNetwork.stop();
|
|
41
56
|
}
|
|
42
57
|
|
|
@@ -45,13 +60,55 @@ export class SecurityPolicyManager {
|
|
|
45
60
|
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
|
|
46
61
|
}
|
|
47
62
|
|
|
63
|
+
public queueObservedIps(ips: string[]): void {
|
|
64
|
+
if (this.isStopping) return;
|
|
65
|
+
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
|
68
|
+
|
|
69
|
+
for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
|
|
70
|
+
if (!this.isPublicIp(ip)) continue;
|
|
71
|
+
if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
|
|
72
|
+
|
|
73
|
+
const lastQueuedAt = this.lastQueuedAt.get(ip);
|
|
74
|
+
if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
|
|
75
|
+
|
|
76
|
+
if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
|
|
77
|
+
const droppedIp = this.observationQueue.shift();
|
|
78
|
+
if (droppedIp) this.queuedObservations.delete(droppedIp);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.observationQueue.push(ip);
|
|
82
|
+
this.queuedObservations.add(ip);
|
|
83
|
+
this.lastQueuedAt.set(ip, now);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.pruneQueuedIpMemory(now);
|
|
87
|
+
this.scheduleQueueDrain();
|
|
88
|
+
}
|
|
89
|
+
|
|
48
90
|
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
|
|
49
91
|
const ip = this.normalizeIp(ipAddress);
|
|
50
|
-
if (!ip || !this.isPublicIp(ip)
|
|
92
|
+
if (!ip || !this.isPublicIp(ip)) {
|
|
51
93
|
return;
|
|
52
94
|
}
|
|
53
95
|
|
|
54
|
-
this.inFlightObservations.
|
|
96
|
+
const existingObservation = this.inFlightObservations.get(ip);
|
|
97
|
+
if (existingObservation) {
|
|
98
|
+
await existingObservation;
|
|
99
|
+
if (!options.force) return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const observationPromise = this.performObserveIp(ip, options).finally(() => {
|
|
103
|
+
if (this.inFlightObservations.get(ip) === observationPromise) {
|
|
104
|
+
this.inFlightObservations.delete(ip);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
this.inFlightObservations.set(ip, observationPromise);
|
|
108
|
+
await observationPromise;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
|
|
55
112
|
try {
|
|
56
113
|
const now = Date.now();
|
|
57
114
|
let doc = await IpIntelligenceDoc.findByIp(ip);
|
|
@@ -81,8 +138,6 @@ export class SecurityPolicyManager {
|
|
|
81
138
|
}
|
|
82
139
|
} catch (err) {
|
|
83
140
|
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
|
|
84
|
-
} finally {
|
|
85
|
-
this.inFlightObservations.delete(ip);
|
|
86
141
|
}
|
|
87
142
|
}
|
|
88
143
|
|
|
@@ -90,8 +145,22 @@ export class SecurityPolicyManager {
|
|
|
90
145
|
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
|
91
146
|
}
|
|
92
147
|
|
|
93
|
-
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
|
|
94
|
-
|
|
148
|
+
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
|
|
149
|
+
const limit = Number.isInteger(options.limit) && options.limit! > 0
|
|
150
|
+
? Math.min(options.limit!, 500)
|
|
151
|
+
: undefined;
|
|
152
|
+
|
|
153
|
+
let docs: IpIntelligenceDoc[];
|
|
154
|
+
if (options.ipAddresses?.length) {
|
|
155
|
+
const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
|
156
|
+
const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
|
|
157
|
+
docs = results.filter(Boolean) as IpIntelligenceDoc[];
|
|
158
|
+
} else {
|
|
159
|
+
docs = await IpIntelligenceDoc.findAll();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
|
|
163
|
+
return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
|
|
95
164
|
}
|
|
96
165
|
|
|
97
166
|
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
|
|
@@ -104,6 +173,45 @@ export class SecurityPolicyManager {
|
|
|
104
173
|
return doc ? this.intelligenceFromDoc(doc) : null;
|
|
105
174
|
}
|
|
106
175
|
|
|
176
|
+
private scheduleQueueDrain(): void {
|
|
177
|
+
if (this.queueDrainScheduled || this.isStopping) return;
|
|
178
|
+
this.queueDrainScheduled = true;
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
this.queueDrainScheduled = false;
|
|
181
|
+
this.drainObservationQueue();
|
|
182
|
+
}, 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private drainObservationQueue(): void {
|
|
186
|
+
if (this.isStopping) return;
|
|
187
|
+
|
|
188
|
+
while (
|
|
189
|
+
this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
|
|
190
|
+
this.observationQueue.length > 0
|
|
191
|
+
) {
|
|
192
|
+
const ip = this.observationQueue.shift()!;
|
|
193
|
+
this.queuedObservations.delete(ip);
|
|
194
|
+
this.activeQueuedObservations++;
|
|
195
|
+
void this.observeIp(ip)
|
|
196
|
+
.catch(() => undefined)
|
|
197
|
+
.finally(() => {
|
|
198
|
+
this.activeQueuedObservations--;
|
|
199
|
+
if (this.observationQueue.length > 0) {
|
|
200
|
+
this.scheduleQueueDrain();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private pruneQueuedIpMemory(now: number): void {
|
|
207
|
+
if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
|
|
208
|
+
for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
|
|
209
|
+
if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
|
|
210
|
+
this.lastQueuedAt.delete(ip);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
107
215
|
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
|
|
108
216
|
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
|
|
109
217
|
id: doc.id,
|