feat(security): add queued IP intelligence observation and filtered retrieval for network and security views
This commit is contained in:
@@ -8,6 +8,14 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add queued IP intelligence observation and filtered retrieval for network and security views (security)
|
||||||
|
- Queue observed public IPs from network metrics with throttled background enrichment instead of awaiting lookups during stats collection.
|
||||||
|
- Allow listing IP intelligence records by specific IP addresses and limit through the security handler and request interface.
|
||||||
|
- Update web app state to refresh IP intelligence asynchronously in the background and preserve current UI state during refreshes.
|
||||||
|
- Improve security policy manager observation handling so forced refresh waits for in-flight lookups before fetching updated intelligence.
|
||||||
|
|
||||||
## 2026-05-20 - 13.32.1
|
## 2026-05-20 - 13.32.1
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@
|
|||||||
"@push.rocks/smartmetrics": "^3.0.3",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmigration": "1.4.1",
|
"@push.rocks/smartmigration": "1.4.1",
|
||||||
"@push.rocks/smartmta": "^5.3.3",
|
"@push.rocks/smartmta": "^5.3.3",
|
||||||
"@push.rocks/smartnetwork": "^4.7.1",
|
"@push.rocks/smartnetwork": "^4.7.2",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.4",
|
"@push.rocks/smartpromise": "^4.2.4",
|
||||||
"@push.rocks/smartproxy": "^27.10.3",
|
"@push.rocks/smartproxy": "^27.10.3",
|
||||||
|
|||||||
Generated
+8
-8
@@ -75,8 +75,8 @@ importers:
|
|||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 5.3.3
|
version: 5.3.3
|
||||||
'@push.rocks/smartnetwork':
|
'@push.rocks/smartnetwork':
|
||||||
specifier: ^4.7.1
|
specifier: ^4.7.2
|
||||||
version: 4.7.1
|
version: 4.7.2
|
||||||
'@push.rocks/smartpath':
|
'@push.rocks/smartpath':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -1395,8 +1395,8 @@ packages:
|
|||||||
'@push.rocks/smartmustache@3.0.2':
|
'@push.rocks/smartmustache@3.0.2':
|
||||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.7.1':
|
'@push.rocks/smartnetwork@4.7.2':
|
||||||
resolution: {integrity: sha512-x9SolGn8lU3oh+fKL26dR5dIhsus5f0p/Xiaut2pK5Wamgwrvt5y5To8F+pzF1pQr6yA0XwWZ0Dgoppp2E+ziQ==}
|
resolution: {integrity: sha512-OwT8kwQeEO+E3RuCyCfgQEBz+FyydUVaTBivZzzVchdJCUDgoDkXSnRkbIuGoHd1BfRFkUg9DQlSzt0uDfsIbw==}
|
||||||
|
|
||||||
'@push.rocks/smartnftables@1.2.0':
|
'@push.rocks/smartnftables@1.2.0':
|
||||||
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
|
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
|
||||||
@@ -5306,7 +5306,7 @@ snapshots:
|
|||||||
'@push.rocks/smartjson': 6.0.1
|
'@push.rocks/smartjson': 6.0.1
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartmongo': 7.0.0(socks@2.8.8)
|
'@push.rocks/smartmongo': 7.0.0(socks@2.8.8)
|
||||||
'@push.rocks/smartnetwork': 4.7.1
|
'@push.rocks/smartnetwork': 4.7.2
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.4
|
'@push.rocks/smartpromise': 4.2.4
|
||||||
'@push.rocks/smartrequest': 5.0.3
|
'@push.rocks/smartrequest': 5.0.3
|
||||||
@@ -6115,7 +6115,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.1.0
|
'@push.rocks/smartdelay': 3.1.0
|
||||||
'@push.rocks/smartdns': 7.9.2
|
'@push.rocks/smartdns': 7.9.2
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartnetwork': 4.7.1
|
'@push.rocks/smartnetwork': 4.7.2
|
||||||
'@push.rocks/smartstring': 4.1.1
|
'@push.rocks/smartstring': 4.1.1
|
||||||
'@push.rocks/smarttime': 4.2.3
|
'@push.rocks/smarttime': 4.2.3
|
||||||
'@push.rocks/smartunique': 3.0.9
|
'@push.rocks/smartunique': 3.0.9
|
||||||
@@ -6591,7 +6591,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
handlebars: 4.7.9
|
handlebars: 4.7.9
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.7.1':
|
'@push.rocks/smartnetwork@4.7.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartdns': 7.9.2
|
'@push.rocks/smartdns': 7.9.2
|
||||||
'@push.rocks/smartrust': 1.4.0
|
'@push.rocks/smartrust': 1.4.0
|
||||||
@@ -6654,7 +6654,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.1.0
|
'@push.rocks/smartdelay': 3.1.0
|
||||||
'@push.rocks/smartfs': 1.5.1
|
'@push.rocks/smartfs': 1.5.1
|
||||||
'@push.rocks/smartjimp': 1.2.1
|
'@push.rocks/smartjimp': 1.2.1
|
||||||
'@push.rocks/smartnetwork': 4.7.1
|
'@push.rocks/smartnetwork': 4.7.2
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.4
|
'@push.rocks/smartpromise': 4.2.4
|
||||||
'@push.rocks/smartpuppeteer': 2.0.6(typescript@6.0.3)
|
'@push.rocks/smartpuppeteer': 2.0.6(typescript@6.0.3)
|
||||||
|
|||||||
@@ -22,14 +22,21 @@ function createProxyMetrics(args: {
|
|||||||
backendMetrics?: Map<string, any>;
|
backendMetrics?: Map<string, any>;
|
||||||
protocolCache?: any[];
|
protocolCache?: any[];
|
||||||
requestsTotal?: number;
|
requestsTotal?: number;
|
||||||
|
connectionsByIP?: Map<string, number>;
|
||||||
|
throughputByIP?: Map<string, { in: number; out: number }>;
|
||||||
}) {
|
}) {
|
||||||
|
const connectionsByIP = args.connectionsByIP || new Map<string, number>();
|
||||||
|
const throughputByIP = args.throughputByIP || new Map<string, { in: number; out: number }>();
|
||||||
return {
|
return {
|
||||||
connections: {
|
connections: {
|
||||||
active: () => 0,
|
active: () => 0,
|
||||||
total: () => 0,
|
total: () => 0,
|
||||||
byRoute: () => args.connectionsByRoute,
|
byRoute: () => args.connectionsByRoute,
|
||||||
byIP: () => new Map<string, number>(),
|
byIP: () => connectionsByIP,
|
||||||
topIPs: () => [],
|
topIPs: (limit = 10) => Array.from(connectionsByIP.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([ip, count]) => ({ ip, count })),
|
||||||
domainRequestsByIP: () => args.domainRequestsByIP,
|
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||||
topDomainRequests: () => [],
|
topDomainRequests: () => [],
|
||||||
frontendProtocols: () => emptyProtocolDistribution,
|
frontendProtocols: () => emptyProtocolDistribution,
|
||||||
@@ -42,7 +49,7 @@ function createProxyMetrics(args: {
|
|||||||
custom: () => ({ in: 0, out: 0 }),
|
custom: () => ({ in: 0, out: 0 }),
|
||||||
history: () => [],
|
history: () => [],
|
||||||
byRoute: () => args.throughputByRoute,
|
byRoute: () => args.throughputByRoute,
|
||||||
byIP: () => new Map<string, { in: number; out: number }>(),
|
byIP: () => throughputByIP,
|
||||||
},
|
},
|
||||||
requests: {
|
requests: {
|
||||||
perSecond: () => 0,
|
perSecond: () => 0,
|
||||||
@@ -239,4 +246,37 @@ tap.test('MetricsManager does not duplicate backend active counts onto protocol
|
|||||||
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
|
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
|
||||||
|
const proxyMetrics = createProxyMetrics({
|
||||||
|
connectionsByRoute: new Map(),
|
||||||
|
throughputByRoute: new Map(),
|
||||||
|
domainRequestsByIP: new Map(),
|
||||||
|
connectionsByIP: new Map([
|
||||||
|
['8.8.8.8', 4],
|
||||||
|
['1.1.1.1', 2],
|
||||||
|
]),
|
||||||
|
throughputByIP: new Map([
|
||||||
|
['8.8.8.8', { in: 500, out: 250 }],
|
||||||
|
['1.1.1.1', { in: 1500, out: 1000 }],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedIps: string[][] = [];
|
||||||
|
const manager = new MetricsManager({
|
||||||
|
smartProxy: {
|
||||||
|
getMetrics: () => proxyMetrics,
|
||||||
|
routeManager: { getRoutes: () => [] },
|
||||||
|
},
|
||||||
|
securityPolicyManager: {
|
||||||
|
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await manager.getNetworkStats();
|
||||||
|
|
||||||
|
expect(queuedIps).toHaveLength(1);
|
||||||
|
expect(queuedIps[0]).toContain('8.8.8.8');
|
||||||
|
expect(queuedIps[0]).toContain('1.1.1.1');
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ const clearTestState = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createIntelligenceResult = (asn: number) => ({
|
||||||
|
asn,
|
||||||
|
asnOrg: `ASN ${asn}`,
|
||||||
|
registrantOrg: null,
|
||||||
|
registrantCountry: null,
|
||||||
|
networkRange: null,
|
||||||
|
networkCidrs: null,
|
||||||
|
abuseContact: null,
|
||||||
|
country: null,
|
||||||
|
countryCode: 'US',
|
||||||
|
city: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
accuracyRadius: null,
|
||||||
|
timezone: null,
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
|
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
|
||||||
await testDbPromise;
|
await testDbPromise;
|
||||||
await clearTestState();
|
await clearTestState();
|
||||||
@@ -120,6 +137,60 @@ tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot
|
|||||||
expect(firewall).toEqual({ blockedIps: [] });
|
expect(firewall).toEqual({ blockedIps: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
const manager = new SecurityPolicyManager();
|
||||||
|
|
||||||
|
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
|
||||||
|
const intelligenceDoc = new IpIntelligenceDoc();
|
||||||
|
intelligenceDoc.ipAddress = ipAddress;
|
||||||
|
intelligenceDoc.asn = asn;
|
||||||
|
intelligenceDoc.asnOrg = `ASN ${asn}`;
|
||||||
|
intelligenceDoc.firstSeenAt = Date.now();
|
||||||
|
intelligenceDoc.lastSeenAt = Date.now();
|
||||||
|
intelligenceDoc.updatedAt = Date.now();
|
||||||
|
intelligenceDoc.seenCount = 1;
|
||||||
|
await intelligenceDoc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
|
||||||
|
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0].ipAddress).toEqual('1.1.1.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
|
||||||
|
|
||||||
|
let releaseFirstLookup!: () => void;
|
||||||
|
let lookupCount = 0;
|
||||||
|
(manager as any).smartNetwork = {
|
||||||
|
getIpIntelligence: async () => {
|
||||||
|
lookupCount++;
|
||||||
|
if (lookupCount === 1) {
|
||||||
|
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
|
||||||
|
return createIntelligenceResult(64500);
|
||||||
|
}
|
||||||
|
return createIntelligenceResult(64501);
|
||||||
|
},
|
||||||
|
stop: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundObservation = manager.observeIp('8.8.8.8');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
|
||||||
|
releaseFirstLookup();
|
||||||
|
|
||||||
|
const record = await forcedRefresh;
|
||||||
|
await backgroundObservation;
|
||||||
|
|
||||||
|
expect(lookupCount).toEqual(2);
|
||||||
|
expect(record?.asn).toEqual(64501);
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('cleanup security policy test db', async () => {
|
tap.test('cleanup security policy test db', async () => {
|
||||||
const dbHandle = await testDbPromise;
|
const dbHandle = await testDbPromise;
|
||||||
await clearTestState();
|
await clearTestState();
|
||||||
|
|||||||
@@ -725,7 +725,10 @@ export class MetricsManager {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||||
|
|
||||||
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
|
this.dcRouter.securityPolicyManager?.queueObservedIps([
|
||||||
|
...topIPs.map((item) => item.ip),
|
||||||
|
...topIPsByBandwidth.map((item) => item.ip),
|
||||||
|
]);
|
||||||
|
|
||||||
// Build domain activity using per-IP domain request counts from Rust engine
|
// Build domain activity using per-IP domain request counts from Rust engine
|
||||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||||
|
|||||||
@@ -180,7 +180,14 @@ export class SecurityHandler {
|
|||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
||||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||||
return { records: manager ? await manager.listIpIntelligence() : [] };
|
return {
|
||||||
|
records: manager
|
||||||
|
? await manager.listIpIntelligence({
|
||||||
|
ipAddresses: dataArg.ipAddresses,
|
||||||
|
limit: dataArg.limit,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,12 +19,24 @@ export interface IRemoteIngressFirewallSnapshot {
|
|||||||
blockedIps: string[];
|
blockedIps: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OBSERVED_IP_QUEUE_LIMIT = 512;
|
||||||
|
const OBSERVED_IP_BATCH_LIMIT = 20;
|
||||||
|
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
|
||||||
|
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
|
||||||
|
|
||||||
export class SecurityPolicyManager {
|
export class SecurityPolicyManager {
|
||||||
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
||||||
cacheTtl: 24 * 60 * 60 * 1000,
|
cacheTtl: 24 * 60 * 60 * 1000,
|
||||||
|
ipIntelligenceTimeout: 5_000,
|
||||||
});
|
});
|
||||||
private readonly intelligenceRefreshMs: number;
|
private readonly intelligenceRefreshMs: number;
|
||||||
private readonly inFlightObservations = new Set<string>();
|
private readonly inFlightObservations = new Map<string, Promise<void>>();
|
||||||
|
private readonly queuedObservations = new Set<string>();
|
||||||
|
private readonly observationQueue: string[] = [];
|
||||||
|
private readonly lastQueuedAt = new Map<string, number>();
|
||||||
|
private activeQueuedObservations = 0;
|
||||||
|
private queueDrainScheduled = false;
|
||||||
|
private isStopping = false;
|
||||||
private readonly onPolicyChanged?: () => void | Promise<void>;
|
private readonly onPolicyChanged?: () => void | Promise<void>;
|
||||||
|
|
||||||
constructor(options: ISecurityPolicyManagerOptions = {}) {
|
constructor(options: ISecurityPolicyManagerOptions = {}) {
|
||||||
@@ -37,6 +49,9 @@ export class SecurityPolicyManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
|
this.isStopping = true;
|
||||||
|
this.observationQueue.length = 0;
|
||||||
|
this.queuedObservations.clear();
|
||||||
await this.smartNetwork.stop();
|
await this.smartNetwork.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,13 +60,55 @@ export class SecurityPolicyManager {
|
|||||||
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
|
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public queueObservedIps(ips: string[]): void {
|
||||||
|
if (this.isStopping) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
||||||
|
|
||||||
|
for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
|
||||||
|
if (!this.isPublicIp(ip)) continue;
|
||||||
|
if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
|
||||||
|
|
||||||
|
const lastQueuedAt = this.lastQueuedAt.get(ip);
|
||||||
|
if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
|
||||||
|
|
||||||
|
if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
|
||||||
|
const droppedIp = this.observationQueue.shift();
|
||||||
|
if (droppedIp) this.queuedObservations.delete(droppedIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.observationQueue.push(ip);
|
||||||
|
this.queuedObservations.add(ip);
|
||||||
|
this.lastQueuedAt.set(ip, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pruneQueuedIpMemory(now);
|
||||||
|
this.scheduleQueueDrain();
|
||||||
|
}
|
||||||
|
|
||||||
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
|
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
|
||||||
const ip = this.normalizeIp(ipAddress);
|
const ip = this.normalizeIp(ipAddress);
|
||||||
if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
|
if (!ip || !this.isPublicIp(ip)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inFlightObservations.add(ip);
|
const existingObservation = this.inFlightObservations.get(ip);
|
||||||
|
if (existingObservation) {
|
||||||
|
await existingObservation;
|
||||||
|
if (!options.force) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observationPromise = this.performObserveIp(ip, options).finally(() => {
|
||||||
|
if (this.inFlightObservations.get(ip) === observationPromise) {
|
||||||
|
this.inFlightObservations.delete(ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.inFlightObservations.set(ip, observationPromise);
|
||||||
|
await observationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let doc = await IpIntelligenceDoc.findByIp(ip);
|
let doc = await IpIntelligenceDoc.findByIp(ip);
|
||||||
@@ -81,8 +138,6 @@ export class SecurityPolicyManager {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
|
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
|
||||||
} finally {
|
|
||||||
this.inFlightObservations.delete(ip);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +145,22 @@ export class SecurityPolicyManager {
|
|||||||
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
|
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
|
||||||
return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc));
|
const limit = Number.isInteger(options.limit) && options.limit! > 0
|
||||||
|
? Math.min(options.limit!, 500)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let docs: IpIntelligenceDoc[];
|
||||||
|
if (options.ipAddresses?.length) {
|
||||||
|
const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
||||||
|
const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
|
||||||
|
docs = results.filter(Boolean) as IpIntelligenceDoc[];
|
||||||
|
} else {
|
||||||
|
docs = await IpIntelligenceDoc.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
|
||||||
|
return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
|
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
|
||||||
@@ -104,6 +173,45 @@ export class SecurityPolicyManager {
|
|||||||
return doc ? this.intelligenceFromDoc(doc) : null;
|
return doc ? this.intelligenceFromDoc(doc) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleQueueDrain(): void {
|
||||||
|
if (this.queueDrainScheduled || this.isStopping) return;
|
||||||
|
this.queueDrainScheduled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.queueDrainScheduled = false;
|
||||||
|
this.drainObservationQueue();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drainObservationQueue(): void {
|
||||||
|
if (this.isStopping) return;
|
||||||
|
|
||||||
|
while (
|
||||||
|
this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
|
||||||
|
this.observationQueue.length > 0
|
||||||
|
) {
|
||||||
|
const ip = this.observationQueue.shift()!;
|
||||||
|
this.queuedObservations.delete(ip);
|
||||||
|
this.activeQueuedObservations++;
|
||||||
|
void this.observeIp(ip)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
this.activeQueuedObservations--;
|
||||||
|
if (this.observationQueue.length > 0) {
|
||||||
|
this.scheduleQueueDrain();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneQueuedIpMemory(now: number): void {
|
||||||
|
if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
|
||||||
|
for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
|
||||||
|
if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
|
||||||
|
this.lastQueuedAt.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
|
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
|
||||||
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
|
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces.
|
|||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
apiToken?: string;
|
apiToken?: string;
|
||||||
|
ipAddresses?: string[];
|
||||||
|
limit?: number;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
records: IIpIntelligenceRecord[];
|
records: IIpIntelligenceRecord[];
|
||||||
|
|||||||
+82
-48
@@ -582,6 +582,52 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const backgroundRefreshesInFlight = new Set<string>();
|
||||||
|
|
||||||
|
function runBackgroundRefresh(key: string, errorMessage: string, task: () => Promise<void>): void {
|
||||||
|
if (backgroundRefreshesInFlight.has(key)) return;
|
||||||
|
backgroundRefreshesInFlight.add(key);
|
||||||
|
void task()
|
||||||
|
.catch((error) => console.error(errorMessage, error))
|
||||||
|
.finally(() => backgroundRefreshesInFlight.delete(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshNetworkIpIntelligence(identity: interfaces.data.IIdentity, ipAddresses: string[]): void {
|
||||||
|
const ips = [...new Set(ipAddresses.filter(Boolean))].slice(0, 100);
|
||||||
|
if (ips.length === 0) return;
|
||||||
|
|
||||||
|
runBackgroundRefresh('networkIpIntelligence', 'IP intelligence refresh failed:', async () => {
|
||||||
|
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListIpIntelligence
|
||||||
|
>('/typedrequest', 'listIpIntelligence');
|
||||||
|
const intelligenceResponse = await intelligenceRequest.fire({
|
||||||
|
identity,
|
||||||
|
ipAddresses: ips,
|
||||||
|
limit: Math.max(100, ips.length),
|
||||||
|
});
|
||||||
|
networkStatePart.setState({
|
||||||
|
...networkStatePart.getState()!,
|
||||||
|
ipIntelligence: intelligenceResponse.records || [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSecurityIpIntelligence(identity: interfaces.data.IIdentity): void {
|
||||||
|
runBackgroundRefresh('securityIpIntelligence', 'Security IP intelligence refresh failed:', async () => {
|
||||||
|
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListIpIntelligence
|
||||||
|
>('/typedrequest', 'listIpIntelligence');
|
||||||
|
const intelligenceResponse = await intelligenceRequest.fire({
|
||||||
|
identity,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
securityPolicyStatePart.setState({
|
||||||
|
...securityPolicyStatePart.getState()!,
|
||||||
|
ipIntelligence: intelligenceResponse.records || [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch Network Stats Action
|
// Fetch Network Stats Action
|
||||||
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg): Promise<INetworkState> => {
|
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg): Promise<INetworkState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
@@ -594,18 +640,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
interfaces.requests.IReq_GetNetworkStats
|
interfaces.requests.IReq_GetNetworkStats
|
||||||
>('/typedrequest', 'getNetworkStats');
|
>('/typedrequest', 'getNetworkStats');
|
||||||
|
|
||||||
const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const networkStatsResponse = await networkStatsRequest.fire({
|
||||||
interfaces.requests.IReq_ListIpIntelligence
|
identity: context.identity,
|
||||||
>('/typedrequest', 'listIpIntelligence');
|
});
|
||||||
|
|
||||||
const [networkStatsResponse, ipIntelligenceResponse] = await Promise.all([
|
|
||||||
networkStatsRequest.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
}),
|
|
||||||
ipIntelligenceRequest.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Use the connections data for the connection list
|
// Use the connections data for the connection list
|
||||||
// and network stats for throughput and IP analytics
|
// and network stats for throughput and IP analytics
|
||||||
@@ -637,6 +674,12 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refreshNetworkIpIntelligence(context.identity, [
|
||||||
|
...Object.keys(connectionsByIP),
|
||||||
|
...(networkStatsResponse.topIPs || []).map((item) => item.ip),
|
||||||
|
...(networkStatsResponse.topIPsByBandwidth || []).map((item) => item.ip),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connections,
|
connections,
|
||||||
connectionsByIP,
|
connectionsByIP,
|
||||||
@@ -647,7 +690,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
topIPs: networkStatsResponse.topIPs || [],
|
topIPs: networkStatsResponse.topIPs || [],
|
||||||
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
|
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
|
||||||
throughputByIP: networkStatsResponse.throughputByIP || [],
|
throughputByIP: networkStatsResponse.throughputByIP || [],
|
||||||
ipIntelligence: ipIntelligenceResponse.records || [],
|
ipIntelligence: currentState.ipIntelligence,
|
||||||
domainActivity: networkStatsResponse.domainActivity || [],
|
domainActivity: networkStatsResponse.domainActivity || [],
|
||||||
throughputHistory: networkStatsResponse.throughputHistory || [],
|
throughputHistory: networkStatsResponse.throughputHistory || [],
|
||||||
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
||||||
@@ -683,9 +726,6 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
|
|||||||
const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_ListSecurityBlockRules
|
interfaces.requests.IReq_ListSecurityBlockRules
|
||||||
>('/typedrequest', 'listSecurityBlockRules');
|
>('/typedrequest', 'listSecurityBlockRules');
|
||||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
||||||
interfaces.requests.IReq_ListIpIntelligence
|
|
||||||
>('/typedrequest', 'listIpIntelligence');
|
|
||||||
const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_GetCompiledSecurityPolicy
|
interfaces.requests.IReq_GetCompiledSecurityPolicy
|
||||||
>('/typedrequest', 'getCompiledSecurityPolicy');
|
>('/typedrequest', 'getCompiledSecurityPolicy');
|
||||||
@@ -693,16 +733,17 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
|
|||||||
interfaces.requests.IReq_ListSecurityPolicyAudit
|
interfaces.requests.IReq_ListSecurityPolicyAudit
|
||||||
>('/typedrequest', 'listSecurityPolicyAudit');
|
>('/typedrequest', 'listSecurityPolicyAudit');
|
||||||
|
|
||||||
const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
|
const [rulesResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
|
||||||
rulesRequest.fire({ identity: context.identity }),
|
rulesRequest.fire({ identity: context.identity }),
|
||||||
intelligenceRequest.fire({ identity: context.identity }),
|
|
||||||
compiledPolicyRequest.fire({ identity: context.identity }),
|
compiledPolicyRequest.fire({ identity: context.identity }),
|
||||||
auditRequest.fire({ identity: context.identity, limit: 100 }),
|
auditRequest.fire({ identity: context.identity, limit: 100 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
refreshSecurityIpIntelligence(context.identity);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: rulesResponse.rules || [],
|
rules: rulesResponse.rules || [],
|
||||||
ipIntelligence: intelligenceResponse.records || [],
|
ipIntelligence: currentState.ipIntelligence,
|
||||||
compiledPolicy: compiledPolicyResponse.policy,
|
compiledPolicy: compiledPolicyResponse.policy,
|
||||||
auditEvents: auditResponse.events || [],
|
auditEvents: auditResponse.events || [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -835,7 +876,15 @@ export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction<
|
|||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
|
return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
|
||||||
}
|
}
|
||||||
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
const refreshedState = await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
||||||
|
if (!response.record) return refreshedState;
|
||||||
|
return {
|
||||||
|
...refreshedState,
|
||||||
|
ipIntelligence: [
|
||||||
|
response.record,
|
||||||
|
...refreshedState.ipIntelligence.filter((record) => record.ipAddress !== response.record!.ipAddress),
|
||||||
|
],
|
||||||
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -3112,53 +3161,38 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
refreshNetworkIpIntelligence(context.identity, [
|
||||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
...network.connectionDetails.map((conn) => conn.remoteAddress),
|
||||||
interfaces.requests.IReq_ListIpIntelligence
|
...network.topEndpoints.map((endpoint) => endpoint.endpoint),
|
||||||
>('/typedrequest', 'listIpIntelligence');
|
...(network.topEndpointsByBandwidth || []).map((endpoint) => endpoint.endpoint),
|
||||||
const intelligenceResponse = await intelligenceRequest.fire({ identity: context.identity });
|
]);
|
||||||
networkStatePart.setState({
|
|
||||||
...networkStatePart.getState()!,
|
|
||||||
ipIntelligence: intelligenceResponse.records || [],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('IP intelligence refresh failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentView === 'security') {
|
if (currentView === 'security') {
|
||||||
try {
|
runBackgroundRefresh('securityPolicy', 'Security policy refresh failed:', async () => {
|
||||||
await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
|
await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Security policy refresh failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh certificate data if on Domains > Certificates subview
|
// Refresh certificate data if on Domains > Certificates subview
|
||||||
if (currentView === 'domains' && currentSubview === 'certificates') {
|
if (currentView === 'domains' && currentSubview === 'certificates') {
|
||||||
try {
|
runBackgroundRefresh('certificates', 'Certificate refresh failed:', async () => {
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Certificate refresh failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh remote ingress data if on the Network → Remote Ingress subview
|
// Refresh remote ingress data if on the Network → Remote Ingress subview
|
||||||
if (currentView === 'network' && currentSubview === 'remoteingress') {
|
if (currentView === 'network' && currentSubview === 'remoteingress') {
|
||||||
try {
|
runBackgroundRefresh('remoteIngress', 'Remote ingress refresh failed:', async () => {
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Remote ingress refresh failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh VPN data if on the Network → VPN subview
|
// Refresh VPN data if on the Network → VPN subview
|
||||||
if (currentView === 'network' && currentSubview === 'vpn') {
|
if (currentView === 'network' && currentSubview === 'vpn') {
|
||||||
try {
|
runBackgroundRefresh('vpn', 'VPN refresh failed:', async () => {
|
||||||
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
||||||
} catch (error) {
|
});
|
||||||
console.error('VPN refresh failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Combined refresh failed:', error);
|
console.error('Combined refresh failed:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user