From e5c3578163ac5c13ebca87b17ab3522a8da91526 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 26 Apr 2026 19:51:08 +0000 Subject: [PATCH] feat(security): add security policy management and IP intelligence operations to the ops UI --- changelog.md | 8 + ts/00_commitinfo_data.ts | 2 +- ts/opsserver/handlers/security.handler.ts | 38 ++ .../classes.security-policy-manager.ts | 33 +- ts_interfaces/requests/security-policy.ts | 45 ++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 239 ++++++++- .../network/ops-view-network-activity.ts | 221 ++++++++- .../security/ops-view-security-blocked.ts | 465 ++++++++++++++++-- 9 files changed, 991 insertions(+), 62 deletions(-) diff --git a/changelog.md b/changelog.md index 3cd1dc5..7028ac8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-26 - 13.24.0 - feat(security) +add security policy management and IP intelligence operations to the ops UI + +- adds typed request endpoints to fetch compiled security policy, list audit events, and force-refresh IP intelligence +- introduces dedicated security policy state and actions for loading, creating, updating, deleting, and refreshing security data +- enhances the network activity view with IP intelligence columns, detail dialogs, and block-rule actions +- expands the security blocked view into a full management interface for rules, compiled policy, IP intelligence, and audit history + ## 2026-04-26 - 13.23.0 - feat(security) add managed security policies with IP intelligence and remote ingress firewall propagation diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c73e566..6d33f92 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.23.0', + version: '13.24.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/opsserver/handlers/security.handler.ts b/ts/opsserver/handlers/security.handler.ts index 5b834be..61eb5d0 100644 --- a/ts/opsserver/handlers/security.handler.ts +++ b/ts/opsserver/handlers/security.handler.ts @@ -178,6 +178,30 @@ export class SecurityHandler { ), ); + router.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getCompiledSecurityPolicy', + async () => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + return { + policy: manager + ? await manager.compilePolicy() + : { blockedIps: [], blockedCidrs: [] }, + }; + }, + ), + ); + + router.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'listSecurityPolicyAudit', + async (dataArg) => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + return { events: manager ? await manager.listAuditEvents(dataArg.limit || 100) : [] }; + }, + ), + ); + const adminRouter = this.opsServerRef.adminRouter; adminRouter.addTypedHandler( @@ -226,6 +250,20 @@ export class SecurityHandler { }, ), ); + + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'refreshIpIntelligence', + async (dataArg) => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + if (!manager) return { success: false, message: 'Security policy manager not initialized' }; + const record = await manager.refreshIpIntelligence(dataArg.ipAddress); + return record + ? { success: true, record } + : { success: false, message: 'IP address is invalid or not public' }; + }, + ), + ); } private async collectSecurityMetrics(): Promise<{ diff --git a/ts/security/classes.security-policy-manager.ts b/ts/security/classes.security-policy-manager.ts index 115b9b6..2052870 100644 --- a/ts/security/classes.security-policy-manager.ts +++ b/ts/security/classes.security-policy-manager.ts @@ -5,6 +5,7 @@ import type { IIpIntelligenceRecord, ISecurityBlockRule, ISecurityCompiledPolicy, + ISecurityPolicyAuditEvent, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType, } from '../../ts_interfaces/data/security-policy.js'; @@ -44,7 +45,7 @@ export class SecurityPolicyManager { await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip))); } - public async observeIp(ipAddress: string): Promise { + public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise { const ip = this.normalizeIp(ipAddress); if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) { return; @@ -54,7 +55,7 @@ export class SecurityPolicyManager { try { const now = Date.now(); let doc = await IpIntelligenceDoc.findByIp(ip); - if (doc && now - doc.updatedAt < this.intelligenceRefreshMs) { + if (doc && !options.force && now - doc.updatedAt < this.intelligenceRefreshMs) { if (now - doc.lastSeenAt > 60_000) { doc.lastSeenAt = now; doc.seenCount = (doc.seenCount || 0) + 1; @@ -90,7 +91,31 @@ export class SecurityPolicyManager { } public async listIpIntelligence(): Promise { - return (await IpIntelligenceDoc.findAll()).map((doc) => ({ + return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc)); + } + + public async refreshIpIntelligence(ipAddress: string): Promise { + const ip = this.normalizeIp(ipAddress); + if (!ip || !this.isPublicIp(ip)) { + return null; + } + await this.observeIp(ip, { force: true }); + const doc = await IpIntelligenceDoc.findByIp(ip); + return doc ? this.intelligenceFromDoc(doc) : null; + } + + public async listAuditEvents(limit = 100): Promise { + return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({ + id: doc.id, + action: doc.action, + actor: doc.actor, + details: doc.details, + createdAt: doc.createdAt, + })); + } + + private intelligenceFromDoc(doc: IpIntelligenceDoc): IIpIntelligenceRecord { + return { ipAddress: doc.ipAddress, asn: doc.asn, asnOrg: doc.asnOrg, @@ -109,7 +134,7 @@ export class SecurityPolicyManager { lastSeenAt: doc.lastSeenAt, updatedAt: doc.updatedAt, seenCount: doc.seenCount, - })); + }; } public async createBlockRule(input: { diff --git a/ts_interfaces/requests/security-policy.ts b/ts_interfaces/requests/security-policy.ts index 015615f..04a9baa 100644 --- a/ts_interfaces/requests/security-policy.ts +++ b/ts_interfaces/requests/security-policy.ts @@ -3,6 +3,8 @@ import type * as authInterfaces from '../data/auth.js'; import type { IIpIntelligenceRecord, ISecurityBlockRule, + ISecurityCompiledPolicy, + ISecurityPolicyAuditEvent, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType, } from '../data/security-policy.js'; @@ -87,3 +89,46 @@ export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces. records: IIpIntelligenceRecord[]; }; } + +export interface IReq_GetCompiledSecurityPolicy extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetCompiledSecurityPolicy +> { + method: 'getCompiledSecurityPolicy'; + request: { + identity: authInterfaces.IIdentity; + }; + response: { + policy: ISecurityCompiledPolicy; + }; +} + +export interface IReq_ListSecurityPolicyAudit extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ListSecurityPolicyAudit +> { + method: 'listSecurityPolicyAudit'; + request: { + identity: authInterfaces.IIdentity; + limit?: number; + }; + response: { + events: ISecurityPolicyAuditEvent[]; + }; +} + +export interface IReq_RefreshIpIntelligence extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RefreshIpIntelligence +> { + method: 'refreshIpIntelligence'; + request: { + identity: authInterfaces.IIdentity; + ipAddress: string; + }; + response: { + success: boolean; + record?: IIpIntelligenceRecord; + message?: string; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index c73e566..6d33f92 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.23.0', + version: '13.24.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 212088d..760669e 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -54,6 +54,7 @@ export interface INetworkState { topIPs: Array<{ ip: string; count: number }>; topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>; throughputByIP: Array<{ ip: string; in: number; out: number }>; + ipIntelligence: interfaces.data.IIpIntelligenceRecord[]; domainActivity: interfaces.data.IDomainActivity[]; throughputHistory: Array<{ timestamp: number; in: number; out: number }>; requestsPerSecond: number; @@ -66,6 +67,16 @@ export interface INetworkState { error: string | null; } +export interface ISecurityPolicyState { + rules: interfaces.data.ISecurityBlockRule[]; + ipIntelligence: interfaces.data.IIpIntelligenceRecord[]; + compiledPolicy: interfaces.data.ISecurityCompiledPolicy | null; + auditEvents: interfaces.data.ISecurityPolicyAuditEvent[]; + isLoading: boolean; + error: string | null; + lastUpdated: number; +} + export interface ICertificateState { certificates: interfaces.requests.ICertificateInfo[]; summary: { total: number; valid: number; expiring: number; expired: number; failed: number; unknown: number }; @@ -164,6 +175,7 @@ export const networkStatePart = await appState.getStatePart( topIPs: [], topIPsByBandwidth: [], throughputByIP: [], + ipIntelligence: [], domainActivity: [], throughputHistory: [], requestsPerSecond: 0, @@ -178,6 +190,20 @@ export const networkStatePart = await appState.getStatePart( 'soft' ); +export const securityPolicyStatePart = await appState.getStatePart( + 'securityPolicy', + { + rules: [], + ipIntelligence: [], + compiledPolicy: null, + auditEvents: [], + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft', +); + export const emailOpsStatePart = await appState.getStatePart( 'emailOps', { @@ -517,9 +543,18 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat interfaces.requests.IReq_GetNetworkStats >('/typedrequest', 'getNetworkStats'); - const networkStatsResponse = await networkStatsRequest.fire({ - identity: context.identity, - }); + const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ListIpIntelligence + >('/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 // and network stats for throughput and IP analytics @@ -561,6 +596,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat topIPs: networkStatsResponse.topIPs || [], topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [], throughputByIP: networkStatsResponse.throughputByIP || [], + ipIntelligence: ipIntelligenceResponse.records || [], domainActivity: networkStatsResponse.domainActivity || [], throughputHistory: networkStatsResponse.throughputHistory || [], requestsPerSecond: networkStatsResponse.requestsPerSecond || 0, @@ -582,6 +618,182 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat } }); +// ============================================================================ +// Security Policy Actions +// ============================================================================ + +export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction( + async (statePartArg): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_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< + interfaces.requests.IReq_GetCompiledSecurityPolicy + >('/typedrequest', 'getCompiledSecurityPolicy'); + const auditRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ListSecurityPolicyAudit + >('/typedrequest', 'listSecurityPolicyAudit'); + + const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([ + rulesRequest.fire({ identity: context.identity }), + intelligenceRequest.fire({ identity: context.identity }), + compiledPolicyRequest.fire({ identity: context.identity }), + auditRequest.fire({ identity: context.identity, limit: 100 }), + ]); + + return { + rules: rulesResponse.rules || [], + ipIntelligence: intelligenceResponse.records || [], + compiledPolicy: compiledPolicyResponse.policy, + auditEvents: auditResponse.events || [], + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error: unknown) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch security policy', + }; + } + }, +); + +export const createSecurityBlockRuleAction = securityPolicyStatePart.createAction<{ + type: interfaces.data.TSecurityBlockRuleType; + value: string; + matchMode?: interfaces.data.TSecurityBlockRuleMatchMode; + reason?: string; + enabled?: boolean; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateSecurityBlockRule + >('/typedrequest', 'createSecurityBlockRule'); + + const response = await request.fire({ + identity: context.identity, + type: dataArg.type, + value: dataArg.value, + matchMode: dataArg.matchMode, + reason: dataArg.reason, + enabled: dataArg.enabled, + }); + + if (!response.success) { + return { ...currentState, error: response.message || 'Failed to create security block rule' }; + } + + return await actionContext!.dispatch(fetchSecurityPolicyAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to create security block rule', + }; + } +}); + +export const updateSecurityBlockRuleAction = securityPolicyStatePart.createAction<{ + id: string; + value?: string; + matchMode?: interfaces.data.TSecurityBlockRuleMatchMode; + reason?: string; + enabled?: boolean; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateSecurityBlockRule + >('/typedrequest', 'updateSecurityBlockRule'); + + const response = await request.fire({ + identity: context.identity, + id: dataArg.id, + value: dataArg.value, + matchMode: dataArg.matchMode, + reason: dataArg.reason, + enabled: dataArg.enabled, + }); + + if (!response.success) { + return { ...currentState, error: response.message || 'Failed to update security block rule' }; + } + + return await actionContext!.dispatch(fetchSecurityPolicyAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to update security block rule', + }; + } +}); + +export const deleteSecurityBlockRuleAction = securityPolicyStatePart.createAction( + async (statePartArg, ruleId, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteSecurityBlockRule + >('/typedrequest', 'deleteSecurityBlockRule'); + + const response = await request.fire({ identity: context.identity, id: ruleId }); + if (!response.success) { + return { ...currentState, error: response.message || 'Failed to delete security block rule' }; + } + + return await actionContext!.dispatch(fetchSecurityPolicyAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to delete security block rule', + }; + } + }, +); + +export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction( + async (statePartArg, ipAddress, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_RefreshIpIntelligence + >('/typedrequest', 'refreshIpIntelligence'); + const response = await request.fire({ identity: context.identity, ipAddress }); + if (!response.success) { + return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' }; + } + return await actionContext!.dispatch(fetchSecurityPolicyAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to refresh IP intelligence', + }; + } + }, +); + // ============================================================================ // Email Operations Actions // ============================================================================ @@ -2665,6 +2877,27 @@ async function dispatchCombinedRefreshActionInner() { isLoading: false, error: null, }); + + try { + const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ListIpIntelligence + >('/typedrequest', 'listIpIntelligence'); + 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') { + try { + await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null); + } catch (error) { + console.error('Security policy refresh failed:', error); + } } // Refresh certificate data if on Domains > Certificates subview diff --git a/ts_web/elements/network/ops-view-network-activity.ts b/ts_web/elements/network/ops-view-network-activity.ts index 25ee2a8..dfef7e1 100644 --- a/ts_web/elements/network/ops-view-network-activity.ts +++ b/ts_web/elements/network/ops-view-network-activity.ts @@ -255,6 +255,17 @@ export class OpsViewNetworkActivity extends DeesElement { color: ${cssManager.bdTheme('#f57c00', '#ff9933')}; } + .intelligenceBadge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + background: ${cssManager.bdTheme('#eef2ff', '#1e1b4b')}; + color: ${cssManager.bdTheme('#4338ca', '#a5b4fc')}; + } + .protocolChartGrid { display: grid; grid-template-columns: repeat(2, 1fr); @@ -345,6 +356,100 @@ export class OpsViewNetworkActivity extends DeesElement { return `${size.toFixed(1)} ${units[unitIndex]}`; } + private formatOptional(value: unknown): string { + if (value === null || value === undefined || value === '') return '-'; + return String(value); + } + + private formatDateTime(timestamp?: number | null): string { + return timestamp ? new Date(timestamp).toLocaleString() : '-'; + } + + private getIpIntelligence(ip: string): interfaces.data.IIpIntelligenceRecord | undefined { + return this.networkState.ipIntelligence?.find((record) => record.ipAddress === ip); + } + + private getIpOrganization(record?: interfaces.data.IIpIntelligenceRecord): string { + return record?.asnOrg || record?.registrantOrg || ''; + } + + private getIpIntelligenceColumns(ip: string): Record { + const record = this.getIpIntelligence(ip); + const organization = this.getIpOrganization(record); + return { + 'Intelligence': record + ? html`${this.formatOptional(organization || record.countryCode || 'Known')}` + : html`Enriching...`, + 'ASN': record?.asn ? `AS${record.asn}` : '-', + 'Organization': this.formatOptional(organization), + 'Country': this.formatOptional(record?.countryCode || record?.country), + 'Network Range': this.formatOptional(record?.networkRange), + 'Last Seen': this.formatDateTime(record?.lastSeenAt), + }; + } + + private getIpDataActions() { + return [ + { + name: 'Refresh Intelligence', + iconName: 'lucide:refresh-cw', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const ip = actionData.item.ip; + await appstate.securityPolicyStatePart.dispatchAction(appstate.refreshIpIntelligenceAction, ip); + await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null); + }, + }, + { + name: 'Block IP', + iconName: 'lucide:shield-ban', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + await this.createBlockRuleDialog('ip', actionData.item.ip, 'Blocked from Network Activity'); + }, + }, + { + name: 'Block Network Range', + iconName: 'lucide:network', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.networkRange), + actionFunc: async (actionData: any) => { + const record = this.getIpIntelligence(actionData.item.ip); + await this.createBlockRuleDialog('cidr', record!.networkRange!, 'Blocked network range from Network Activity'); + }, + }, + { + name: 'Block ASN', + iconName: 'lucide:radio-tower', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.asn), + actionFunc: async (actionData: any) => { + const record = this.getIpIntelligence(actionData.item.ip); + await this.createBlockRuleDialog('asn', String(record!.asn), 'Blocked ASN from Network Activity'); + }, + }, + { + name: 'Block Organization', + iconName: 'lucide:building-2', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpOrganization(this.getIpIntelligence(actionData.item.ip))), + actionFunc: async (actionData: any) => { + const record = this.getIpIntelligence(actionData.item.ip); + await this.createBlockRuleDialog('organization', this.getIpOrganization(record), 'Blocked organization from Network Activity'); + }, + }, + { + name: 'View Intelligence', + iconName: 'lucide:info', + type: ['doubleClick', 'contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)), + actionFunc: async (actionData: any) => { + await this.showIpIntelligenceDetails(actionData.item.ip); + }, + }, + ]; + } + private calculateThroughput(): { in: number; out: number } { // Use real throughput data from network state return { @@ -500,10 +605,12 @@ export class OpsViewNetworkActivity extends DeesElement { 'Bandwidth In': bw ? this.formatBitsPerSecond(bw.in) : '0 bit/s', 'Bandwidth Out': bw ? this.formatBitsPerSecond(bw.out) : '0 bit/s', 'Share': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%', + ...this.getIpIntelligenceColumns(ipData.ip), }; }} + .dataActions=${this.getIpDataActions()} heading1="Top Connected IPs" - heading2="IPs with most active connections and bandwidth" + heading2="IPs with most active connections, bandwidth, and intelligence" searchable .showColumnFilters=${true} .pagination=${false} @@ -529,10 +636,12 @@ export class OpsViewNetworkActivity extends DeesElement { 'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut), 'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut), 'Connections': ipData.count, + ...this.getIpIntelligenceColumns(ipData.ip), }; }} + .dataActions=${this.getIpDataActions()} heading1="Top IPs by Bandwidth" - heading2="IPs with highest throughput" + heading2="IPs with highest throughput and intelligence" searchable .showColumnFilters=${true} .pagination=${false} @@ -678,6 +787,114 @@ export class OpsViewNetworkActivity extends DeesElement { }); } + private getDropdownKey(value: any): string { + return typeof value === 'string' ? value : value?.key || ''; + } + + private async createBlockRuleDialog( + type: interfaces.data.TSecurityBlockRuleType, + value: string, + reason: string, + ): Promise { + const { DeesModal } = await import('@design.estate/dees-catalog'); + const typeOptions = [ + { key: 'ip', option: 'IP address' }, + { key: 'cidr', option: 'CIDR / network range' }, + { key: 'asn', option: 'ASN' }, + { key: 'organization', option: 'Organization' }, + ]; + const matchModeOptions = [ + { key: 'contains', option: 'Organization contains value' }, + { key: 'exact', option: 'Organization exactly matches value' }, + ]; + + await DeesModal.createAndShow({ + heading: 'Create Security Block Rule', + content: html` + + option.key === type)} + > + + + + + + `, + menuOptions: [ + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Create', + iconName: 'lucide:shield-ban', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const selectedType = this.getDropdownKey(data.type) as interfaces.data.TSecurityBlockRuleType; + const selectedValue = String(data.value || '').trim(); + if (!selectedType || !selectedValue) return; + const matchMode = selectedType === 'organization' + ? this.getDropdownKey(data.matchMode) as interfaces.data.TSecurityBlockRuleMatchMode + : undefined; + await appstate.securityPolicyStatePart.dispatchAction(appstate.createSecurityBlockRuleAction, { + type: selectedType, + value: selectedValue, + matchMode, + reason: String(data.reason || '').trim() || undefined, + enabled: data.enabled !== false, + }); + await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null); + await modalArg.destroy(); + }, + }, + ], + }); + } + + private async showIpIntelligenceDetails(ip: string): Promise { + const record = this.getIpIntelligence(ip); + if (!record) return; + const { DeesModal } = await import('@design.estate/dees-catalog'); + + await DeesModal.createAndShow({ + heading: `IP Intelligence: ${ip}`, + content: html` +
+ +
+ `, + menuOptions: [ + { + name: 'Copy Abuse Contact', + iconName: 'lucide:copy', + action: async () => { + if (record.abuseContact) await navigator.clipboard.writeText(record.abuseContact); + }, + }, + { + name: 'Block IP', + iconName: 'lucide:shield-ban', + action: async () => { + await this.createBlockRuleDialog('ip', record.ipAddress, 'Blocked from IP intelligence details'); + }, + }, + ], + }); + } + private async updateNetworkData() { // Track requests/sec history for the trend sparkline (moved out of render) const reqPerSec = this.networkState.requestsPerSecond || 0; diff --git a/ts_web/elements/security/ops-view-security-blocked.ts b/ts_web/elements/security/ops-view-security-blocked.ts index c9e6b77..a2aa5b1 100644 --- a/ts_web/elements/security/ops-view-security-blocked.ts +++ b/ts_web/elements/security/ops-view-security-blocked.ts @@ -1,4 +1,5 @@ import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; import { viewHostCss } from '../shared/css.js'; import { @@ -21,18 +22,23 @@ declare global { @customElement('ops-view-security-blocked') export class OpsViewSecurityBlocked extends DeesElement { @state() - accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!; + accessor securityPolicyState: appstate.ISecurityPolicyState = appstate.securityPolicyStatePart.getState()!; constructor() { super(); - const sub = appstate.statsStatePart + const sub = appstate.securityPolicyStatePart .select((s) => s) .subscribe((s) => { - this.statsState = s; + this.securityPolicyState = s; }); this.rxSubscriptions.push(sub); } + public async connectedCallback() { + await super.connectedCallback(); + await appstate.securityPolicyStatePart.dispatchAction(appstate.fetchSecurityPolicyAction, null); + } + public static styles = [ cssManager.defaultStyles, viewHostCss, @@ -40,79 +46,436 @@ export class OpsViewSecurityBlocked extends DeesElement { dees-statsgrid { margin-bottom: 32px; } + + .sectionStack { + display: flex; + flex-direction: column; + gap: 32px; + } + + .statusBadge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + } + + .statusBadge.enabled { + background: ${cssManager.bdTheme('#e8f5e9', '#1a3a1a')}; + color: ${cssManager.bdTheme('#388e3c', '#66bb6a')}; + } + + .statusBadge.disabled { + background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')}; + color: ${cssManager.bdTheme('#757575', '#999')}; + } + + .typeBadge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + background: ${cssManager.bdTheme('#eef2ff', '#1e1b4b')}; + color: ${cssManager.bdTheme('#4338ca', '#a5b4fc')}; + } + + .errorMessage { + padding: 12px 16px; + border-radius: 8px; + background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; + color: ${cssManager.bdTheme('#b91c1c', '#fca5a5')}; + } `, ]; public render(): TemplateResult { - const metrics = this.statsState.securityMetrics; - - if (!metrics) { - return html` -
-

Loading security metrics...

-
- `; - } - - const blockedIPs: string[] = metrics.blockedIPs || []; + const state = this.securityPolicyState; + const activeRules = state.rules.filter((rule) => rule.enabled); + const disabledRules = state.rules.length - activeRules.length; + const compiledPolicy = state.compiledPolicy || { blockedIps: [], blockedCidrs: [] }; const tiles: IStatsTile[] = [ { - id: 'totalBlocked', - title: 'Blocked IPs', - value: blockedIPs.length, + id: 'activeRules', + title: 'Active Rules', + value: activeRules.length, type: 'number', - icon: 'lucide:ShieldBan', - color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e', - description: 'Currently blocked addresses', + icon: 'lucide:shield-check', + color: activeRules.length > 0 ? '#ef4444' : '#22c55e', + description: `${disabledRules} disabled`, + }, + { + id: 'compiledIps', + title: 'Compiled IPs', + value: compiledPolicy.blockedIps.length, + type: 'number', + icon: 'lucide:server-off', + color: '#ef4444', + description: 'Direct IP blocks enforced by SmartProxy', + }, + { + id: 'compiledCidrs', + title: 'Compiled CIDRs', + value: compiledPolicy.blockedCidrs.length, + type: 'number', + icon: 'lucide:network', + color: '#f97316', + description: 'Network ranges pushed to enforcement layers', + }, + { + id: 'intelligenceRecords', + title: 'IP Intelligence', + value: state.ipIntelligence.length, + type: 'number', + icon: 'lucide:radar', + color: '#6366f1', + description: 'Observed public IPs with enrichment', }, ]; return html` - Blocked IPs + Security Blocking + + ${state.error ? html`
${state.error}
` : html``} +
+ ${this.renderRulesTable()} + ${this.renderCompiledPolicyTable()} + ${this.renderIpIntelligenceTable()} + ${this.renderAuditTable()} +
+ `; + } + + private renderRulesTable(): TemplateResult { + return html` ({ ip }))} - .displayFunction=${(item) => ({ - 'IP Address': item.ip, - 'Reason': 'Suspicious activity', + .heading1=${'Managed Block Rules'} + .heading2=${'Rules compiled into SmartProxy policy and remote ingress edge firewall snapshots'} + .data=${this.securityPolicyState.rules} + .rowKey=${'id'} + .displayFunction=${(rule: interfaces.data.ISecurityBlockRule) => ({ + 'Type': html`${rule.type}`, + 'Value': rule.value, + 'Match': rule.type === 'organization' ? (rule.matchMode || 'contains') : '-', + 'Reason': rule.reason || '-', + 'Status': html`${rule.enabled ? 'Enabled' : 'Disabled'}`, + 'Created': this.formatDateTime(rule.createdAt), + 'Updated': this.formatDateTime(rule.updatedAt), })} - .dataActions=${[ - { - name: 'Unblock', - iconName: 'lucide:shield-off', - type: ['contextmenu' as const], - actionFunc: async (item) => { - await this.unblockIP(item.ip); - }, - }, - { - name: 'Clear All', - iconName: 'lucide:trash-2', - type: ['header' as const], - actionFunc: async () => { - await this.clearBlockedIPs(); - }, - }, - ]} + .dataActions=${this.getRuleActions()} + searchable + .showColumnFilters=${true} + dataName="rule" > `; } - private async clearBlockedIPs() { - // SmartProxy manages IP blocking — not yet exposed via API - alert('Clearing blocked IPs is not yet supported from the UI.'); + private renderCompiledPolicyTable(): TemplateResult { + const policy = this.securityPolicyState.compiledPolicy || { blockedIps: [], blockedCidrs: [] }; + const rows = [ + ...policy.blockedIps.map((value) => ({ type: 'ip', value })), + ...policy.blockedCidrs.map((value) => ({ type: 'cidr', value })), + ]; + + return html` + ({ + 'Enforcement Type': html`${row.type}`, + 'Value': row.value, + })} + searchable + .showColumnFilters=${true} + dataName="compiled rule" + > + `; } - private async unblockIP(ip: string) { - // SmartProxy manages IP blocking — not yet exposed via API - alert(`Unblocking IP ${ip} is not yet supported from the UI.`); + private renderIpIntelligenceTable(): TemplateResult { + return html` + ({ + 'IP Address': record.ipAddress, + 'ASN': record.asn ? `AS${record.asn}` : '-', + 'ASN Org': record.asnOrg || '-', + 'Registrant Org': record.registrantOrg || '-', + 'Country': record.countryCode || record.country || '-', + 'Network Range': record.networkRange || '-', + 'Abuse Contact': record.abuseContact || '-', + 'Seen': record.seenCount, + 'Last Seen': this.formatDateTime(record.lastSeenAt), + })} + .dataActions=${this.getIpIntelligenceActions()} + searchable + .showColumnFilters=${true} + dataName="ip intelligence record" + > + `; + } + + private renderAuditTable(): TemplateResult { + return html` + ({ + 'Time': this.formatDateTime(event.createdAt), + 'Action': event.action, + 'Actor': event.actor, + 'Details': this.formatAuditDetails(event.details), + })} + searchable + .showColumnFilters=${true} + dataName="audit event" + > + `; + } + + private getRuleActions() { + return [ + { + name: 'Create Rule', + iconName: 'lucide:plus', + type: ['header'] as any, + actionFunc: async () => this.showRuleDialog(), + }, + { + name: 'Edit', + iconName: 'lucide:pencil', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => this.showRuleDialog(actionData.item), + }, + { + name: 'Enable', + iconName: 'lucide:play', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled, + actionFunc: async (actionData: any) => { + const rule = actionData.item as interfaces.data.ISecurityBlockRule; + await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, { + id: rule.id, + enabled: true, + }); + }, + }, + { + name: 'Disable', + iconName: 'lucide:pause', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled, + actionFunc: async (actionData: any) => { + const rule = actionData.item as interfaces.data.ISecurityBlockRule; + await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, { + id: rule.id, + enabled: false, + }); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash-2', + type: ['contextmenu'] as any, + actionFunc: async (actionData: any) => { + const rule = actionData.item as interfaces.data.ISecurityBlockRule; + if (!window.confirm(`Delete block rule ${rule.type}:${rule.value}?`)) return; + await appstate.securityPolicyStatePart.dispatchAction(appstate.deleteSecurityBlockRuleAction, rule.id); + }, + }, + ]; + } + + private getIpIntelligenceActions() { + return [ + { + name: 'Refresh Intelligence', + iconName: 'lucide:refresh-cw', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const record = actionData.item as interfaces.data.IIpIntelligenceRecord; + await appstate.securityPolicyStatePart.dispatchAction(appstate.refreshIpIntelligenceAction, record.ipAddress); + }, + }, + { + name: 'Block IP', + iconName: 'lucide:shield-ban', + type: ['contextmenu'] as any, + actionFunc: async (actionData: any) => { + const record = actionData.item as interfaces.data.IIpIntelligenceRecord; + await this.showRuleDialog(undefined, { + type: 'ip', + value: record.ipAddress, + reason: 'Blocked from IP intelligence table', + }); + }, + }, + { + name: 'Block Network Range', + iconName: 'lucide:network', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.networkRange), + actionFunc: async (actionData: any) => { + const record = actionData.item as interfaces.data.IIpIntelligenceRecord; + await this.showRuleDialog(undefined, { + type: 'cidr', + value: record.networkRange || '', + reason: 'Blocked network range from IP intelligence table', + }); + }, + }, + { + name: 'Block ASN', + iconName: 'lucide:radio-tower', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asn), + actionFunc: async (actionData: any) => { + const record = actionData.item as interfaces.data.IIpIntelligenceRecord; + await this.showRuleDialog(undefined, { + type: 'asn', + value: String(record.asn), + reason: 'Blocked ASN from IP intelligence table', + }); + }, + }, + { + name: 'Block Organization', + iconName: 'lucide:building-2', + type: ['contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asnOrg || actionData.item.registrantOrg), + actionFunc: async (actionData: any) => { + const record = actionData.item as interfaces.data.IIpIntelligenceRecord; + await this.showRuleDialog(undefined, { + type: 'organization', + value: record.asnOrg || record.registrantOrg || '', + reason: 'Blocked organization from IP intelligence table', + }); + }, + }, + ]; + } + + private async showRuleDialog( + rule?: interfaces.data.ISecurityBlockRule, + defaults: Partial = {}, + ): Promise { + const { DeesModal } = await import('@design.estate/dees-catalog'); + const typeOptions = [ + { key: 'ip', option: 'IP address' }, + { key: 'cidr', option: 'CIDR / network range' }, + { key: 'asn', option: 'ASN' }, + { key: 'organization', option: 'Organization' }, + ]; + const matchModeOptions = [ + { key: 'contains', option: 'Organization contains value' }, + { key: 'exact', option: 'Organization exactly matches value' }, + ]; + const selectedType = rule?.type || defaults.type || 'ip'; + const selectedMatchMode = rule?.matchMode || defaults.matchMode || 'contains'; + + await DeesModal.createAndShow({ + heading: rule ? `Edit Block Rule: ${rule.type}:${rule.value}` : 'Create Block Rule', + content: html` + + ${rule ? html`` : html` + option.key === selectedType)} + > + `} + + option.key === selectedMatchMode)} + > + + + + `, + menuOptions: [ + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() }, + { + name: rule ? 'Save' : 'Create', + iconName: rule ? 'lucide:check' : 'lucide:plus', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const type = (rule?.type || this.getDropdownKey(data.type)) as interfaces.data.TSecurityBlockRuleType; + const value = String(data.value || '').trim(); + if (!type || !value) return; + const matchMode = type === 'organization' + ? this.getDropdownKey(data.matchMode) as interfaces.data.TSecurityBlockRuleMatchMode + : undefined; + const payload = { + value, + matchMode, + reason: String(data.reason || '').trim() || undefined, + enabled: data.enabled !== false, + }; + if (rule) { + await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, { + id: rule.id, + ...payload, + }); + } else { + await appstate.securityPolicyStatePart.dispatchAction(appstate.createSecurityBlockRuleAction, { + type, + ...payload, + }); + } + await modalArg.destroy(); + }, + }, + ], + }); + } + + private getDropdownKey(value: any): string { + return typeof value === 'string' ? value : value?.key || ''; + } + + private formatDateTime(timestamp?: number): string { + return timestamp ? new Date(timestamp).toLocaleString() : '-'; + } + + private formatAuditDetails(details: Record): string { + const text = JSON.stringify(details); + return text.length > 160 ? `${text.slice(0, 157)}...` : text; } }