From af31982d58ffcd91c34f3d5d7fb85152625d137a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 26 Apr 2026 15:15:27 +0000 Subject: [PATCH] feat(security): add managed security policies with IP intelligence and remote ingress firewall propagation --- changelog.md | 8 + package.json | 4 +- pnpm-lock.yaml | 30 +- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 94 +++++- .../documents/classes.ip-intelligence.doc.ts | 75 +++++ .../classes.security-block-rule.doc.ts | 52 +++ .../classes.security-policy-audit.doc.ts | 33 ++ ts/db/documents/index.ts | 3 + ts/monitoring/classes.metricsmanager.ts | 2 + ts/opsserver/handlers/security.handler.ts | 69 ++++ .../classes.remoteingress-manager.ts | 17 +- .../classes.security-policy-manager.ts | 315 ++++++++++++++++++ ts/security/index.ts | 8 +- ts_interfaces/data/index.ts | 3 +- ts_interfaces/data/security-policy.ts | 37 ++ ts_interfaces/requests/index.ts | 3 +- ts_interfaces/requests/security-policy.ts | 89 +++++ ts_web/00_commitinfo_data.ts | 2 +- 19 files changed, 823 insertions(+), 23 deletions(-) create mode 100644 ts/db/documents/classes.ip-intelligence.doc.ts create mode 100644 ts/db/documents/classes.security-block-rule.doc.ts create mode 100644 ts/db/documents/classes.security-policy-audit.doc.ts create mode 100644 ts/security/classes.security-policy-manager.ts create mode 100644 ts_interfaces/data/security-policy.ts create mode 100644 ts_interfaces/requests/security-policy.ts diff --git a/changelog.md b/changelog.md index b5fa764..3cd1dc5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-26 - 13.23.0 - feat(security) +add managed security policies with IP intelligence and remote ingress firewall propagation + +- introduces a SecurityPolicyManager that observes public IPs, stores IP intelligence, compiles block policies, and audits policy changes +- adds database documents and shared interfaces for security block rules, IP intelligence records, and security policy audit events +- exposes ops/admin request handlers to list IP intelligence and create, update, or delete security block rules +- applies merged security policies to SmartProxy and propagates firewall snapshots to remote ingress edges and tunnel synchronization + ## 2026-04-26 - 13.22.0 - feat(remoteingress) add remote ingress performance configuration and expose tunnel transport metrics diff --git a/package.json b/package.json index b44f733..1a0daee 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@push.rocks/smartnetwork": "^4.6.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", - "@push.rocks/smartproxy": "^27.8.2", + "@push.rocks/smartproxy": "^27.9.0", "@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrx": "^3.0.10", @@ -64,7 +64,7 @@ "@push.rocks/taskbuffer": "^8.0.2", "@serve.zone/catalog": "^2.12.4", "@serve.zone/interfaces": "^5.4.3", - "@serve.zone/remoteingress": "^4.17.0", + "@serve.zone/remoteingress": "^4.17.1", "@tsclass/tsclass": "^9.5.0", "@types/qrcode": "^1.5.6", "lru-cache": "^11.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02b661a..9154efa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,8 +81,8 @@ importers: specifier: ^4.2.3 version: 4.2.3 '@push.rocks/smartproxy': - specifier: ^27.8.2 - version: 27.8.2 + specifier: ^27.9.0 + version: 27.9.0 '@push.rocks/smartradius': specifier: ^1.1.1 version: 1.1.1 @@ -111,8 +111,8 @@ importers: specifier: ^5.4.3 version: 5.4.3 '@serve.zone/remoteingress': - specifier: ^4.17.0 - version: 4.17.0 + specifier: ^4.17.1 + version: 4.17.1 '@tsclass/tsclass': specifier: ^9.5.0 version: 9.5.0 @@ -1263,6 +1263,9 @@ packages: '@push.rocks/smartnftables@1.1.0': resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==} + '@push.rocks/smartnftables@1.2.0': + resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==} + '@push.rocks/smartnpm@2.0.6': resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==} @@ -1284,8 +1287,8 @@ packages: '@push.rocks/smartpromise@4.2.3': resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartproxy@27.8.2': - resolution: {integrity: sha512-4T20SKk4oewAg/ztazxxtkHIip3lM0ksZmXZN/zx2uC68HdZRroK5oekMYcIeD2AfvjGYUK1vI1MMgQz+glHXQ==} + '@push.rocks/smartproxy@27.9.0': + resolution: {integrity: sha512-lzOxueA89pBf4ZcTzF+VkjXQ0es8z8C20PW6FA0HcIzcCpnh4NjLwnXyD8NnTpCf+HKh/EAgD77Kt9Dn+sssUQ==} '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} @@ -1594,8 +1597,8 @@ packages: '@serve.zone/interfaces@5.4.3': resolution: {integrity: sha512-9ijFhHoC7GYyyAUJbBoDYmcoCmIXTFPiD6fI3x68SWiC0xA+2LG0nOe14D32c1QN9X/3i2Ac5/1sUibfjHsIGg==} - '@serve.zone/remoteingress@4.17.0': - resolution: {integrity: sha512-q1g2Zm1Yh825cMiF8/W1iQlOLGqgmWBrtzDqNgF5hH31HP2zHHtC2+XPyB+1kEphsztlXzPMlcRpfCRwuQUexA==} + '@serve.zone/remoteingress@4.17.1': + resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==} '@sindresorhus/is@5.6.0': resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} @@ -6443,6 +6446,11 @@ snapshots: '@push.rocks/smartlog': 3.2.2 '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartnftables@1.2.0': + dependencies: + '@push.rocks/smartlog': 3.2.2 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartnpm@2.0.6': dependencies: '@push.rocks/consolecolor': 2.0.3 @@ -6512,7 +6520,7 @@ snapshots: '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartproxy@27.8.2': + '@push.rocks/smartproxy@27.9.0': dependencies: '@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartlog': 3.2.2 @@ -6933,10 +6941,10 @@ snapshots: '@push.rocks/smartlog-interfaces': 3.0.2 '@tsclass/tsclass': 9.5.0 - '@serve.zone/remoteingress@4.17.0': + '@serve.zone/remoteingress@4.17.1': dependencies: '@push.rocks/qenv': 6.1.3 - '@push.rocks/smartnftables': 1.1.0 + '@push.rocks/smartnftables': 1.2.0 '@push.rocks/smartrust': 1.3.2 '@sindresorhus/is@5.6.0': {} diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3b0b8c4..c73e566 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.22.0', + version: '13.23.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 47cbb39..91b5b0d 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -27,12 +27,13 @@ import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import type { TIpAllowEntry } from './config/classes.route-config-manager.js'; -import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; +import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { DnsManager } from './dns/manager.dns.js'; import { AcmeConfigManager } from './acme/manager.acme-config.js'; import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js'; import type { IRoute } from '../ts_interfaces/data/route-management.js'; +import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js'; export interface IDcRouterOptions { /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ @@ -284,6 +285,7 @@ export class DcRouter { // ACME configuration (DB-backed singleton, replaces tls.contactEmail) public acmeConfigManager?: AcmeConfigManager; public emailDomainManager?: EmailDomainManager; + public securityPolicyManager?: SecurityPolicyManager; // Auto-discovered public IP (populated by generateAuthoritativeRecords) public detectedPublicIp: string | null = null; @@ -471,12 +473,36 @@ export class DcRouter { ); } + // SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence + // and compiles the global block policy for SmartProxy and remote ingress edges. + if (this.options.dbConfig?.enabled !== false) { + this.serviceManager.addService( + new plugins.taskbuffer.Service('SecurityPolicyManager') + .optional() + .dependsOn('DcRouterDb') + .withStart(async () => { + this.securityPolicyManager = new SecurityPolicyManager({ + onPolicyChanged: () => this.applySecurityPolicy(), + }); + await this.securityPolicyManager.start(); + }) + .withStop(async () => { + if (this.securityPolicyManager) { + await this.securityPolicyManager.stop(); + this.securityPolicyManager = undefined; + } + }) + .withRetry({ maxRetries: 1, baseDelayMs: 500 }), + ); + } + // SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled) const smartProxyDeps: string[] = []; if (this.options.dbConfig?.enabled !== false) { smartProxyDeps.push('DcRouterDb'); smartProxyDeps.push('DnsManager'); smartProxyDeps.push('AcmeConfigManager'); + smartProxyDeps.push('SecurityPolicyManager'); } this.serviceManager.addService( new plugins.taskbuffer.Service('SmartProxy') @@ -971,6 +997,12 @@ export class DcRouter { logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); } + const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy(); + const mergedSecurityPolicy = this.mergeSecurityPolicies( + (this.options.smartProxyConfig as any)?.securityPolicy, + compiledSecurityPolicy, + ); + // If we have routes or need a basic SmartProxy instance, create it if (routes.length > 0 || this.options.smartProxyConfig) { logger.log('info', 'Setting up SmartProxy with combined configuration'); @@ -1002,6 +1034,7 @@ export class DcRouter { // --- always set by dcrouter (after spread) --- routes, acme: acmeConfig, + ...(mergedSecurityPolicy ? { securityPolicy: mergedSecurityPolicy } as any : {}), certStore: { loadAll: async () => { const docs = await ProxyCertDoc.findAll(); @@ -1244,8 +1277,60 @@ export class DcRouter { logger.log('info', `SmartProxy started with ${routes.length} routes`); } } - - + + public async applySecurityPolicy(): Promise { + if (!this.securityPolicyManager) { + return; + } + + const compiledSmartProxyPolicy = await this.securityPolicyManager.compileSmartProxyPolicy(); + const mergedSecurityPolicy = this.mergeSecurityPolicies( + (this.options.smartProxyConfig as any)?.securityPolicy, + compiledSmartProxyPolicy, + ); + + if (this.smartProxy && mergedSecurityPolicy) { + const smartProxyWithPolicyApi = this.smartProxy as any; + if (typeof smartProxyWithPolicyApi.updateSecurityPolicy === 'function') { + await smartProxyWithPolicyApi.updateSecurityPolicy(mergedSecurityPolicy); + } + } + + const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall(); + if (this.remoteIngressManager) { + (this.remoteIngressManager as any).setFirewallConfig?.(firewallConfig); + } + if (this.tunnelManager) { + await this.tunnelManager.syncAllowedEdges(); + } + } + + private mergeSecurityPolicies( + ...policies: Array | undefined> + ): ISecurityCompiledPolicy | undefined { + const blockedIps = new Set(); + const blockedCidrs = new Set(); + + for (const policy of policies) { + for (const ip of policy?.blockedIps || []) { + if (ip) blockedIps.add(ip); + } + for (const cidr of policy?.blockedCidrs || []) { + if (cidr) blockedCidrs.add(cidr); + } + } + + if (blockedIps.size === 0 && blockedCidrs.size === 0) { + return undefined; + } + + return { + blockedIps: [...blockedIps].sort(), + blockedCidrs: [...blockedCidrs].sort(), + }; + } + + /** * Generate SmartProxy routes for email configuration @@ -2232,6 +2317,9 @@ export class DcRouter { // Initialize the edge registration manager this.remoteIngressManager = new RemoteIngressManager(); await this.remoteIngressManager.initialize(); + this.remoteIngressManager.setFirewallConfig( + await this.securityPolicyManager?.compileRemoteIngressFirewall(), + ); // Pass current bootstrap routes so the manager can derive edge ports initially. // Once RouteConfigManager applies the full DB set, the onRoutesApplied callback diff --git a/ts/db/documents/classes.ip-intelligence.doc.ts b/ts/db/documents/classes.ip-intelligence.doc.ts new file mode 100644 index 0000000..aed82d8 --- /dev/null +++ b/ts/db/documents/classes.ip-intelligence.doc.ts @@ -0,0 +1,75 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc implements IIpIntelligenceRecord { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public ipAddress!: string; + + @plugins.smartdata.svDb() + public asn: number | null = null; + + @plugins.smartdata.svDb() + public asnOrg: string | null = null; + + @plugins.smartdata.svDb() + public registrantOrg: string | null = null; + + @plugins.smartdata.svDb() + public registrantCountry: string | null = null; + + @plugins.smartdata.svDb() + public networkRange: string | null = null; + + @plugins.smartdata.svDb() + public abuseContact: string | null = null; + + @plugins.smartdata.svDb() + public country: string | null = null; + + @plugins.smartdata.svDb() + public countryCode: string | null = null; + + @plugins.smartdata.svDb() + public city: string | null = null; + + @plugins.smartdata.svDb() + public latitude: number | null = null; + + @plugins.smartdata.svDb() + public longitude: number | null = null; + + @plugins.smartdata.svDb() + public accuracyRadius: number | null = null; + + @plugins.smartdata.svDb() + public timezone: string | null = null; + + @plugins.smartdata.svDb() + public firstSeenAt: number = Date.now(); + + @plugins.smartdata.svDb() + public lastSeenAt: number = Date.now(); + + @plugins.smartdata.svDb() + public updatedAt: number = Date.now(); + + @plugins.smartdata.svDb() + public seenCount: number = 0; + + constructor() { + super(); + } + + public static async findByIp(ipAddress: string): Promise { + return await IpIntelligenceDoc.getInstance({ ipAddress }); + } + + public static async findAll(): Promise { + return await IpIntelligenceDoc.getInstances({}); + } +} diff --git a/ts/db/documents/classes.security-block-rule.doc.ts b/ts/db/documents/classes.security-block-rule.doc.ts new file mode 100644 index 0000000..d71de10 --- /dev/null +++ b/ts/db/documents/classes.security-block-rule.doc.ts @@ -0,0 +1,52 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc implements ISecurityBlockRule { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id!: string; + + @plugins.smartdata.svDb() + public type!: TSecurityBlockRuleType; + + @plugins.smartdata.svDb() + public value!: string; + + @plugins.smartdata.svDb() + public matchMode?: TSecurityBlockRuleMatchMode; + + @plugins.smartdata.svDb() + public enabled: boolean = true; + + @plugins.smartdata.svDb() + public reason?: string; + + @plugins.smartdata.svDb() + public createdAt: number = Date.now(); + + @plugins.smartdata.svDb() + public updatedAt: number = Date.now(); + + @plugins.smartdata.svDb() + public createdBy: string = 'system'; + + constructor() { + super(); + } + + public static async findById(id: string): Promise { + return await SecurityBlockRuleDoc.getInstance({ id }); + } + + public static async findAll(): Promise { + return await SecurityBlockRuleDoc.getInstances({}); + } + + public static async findEnabled(): Promise { + return await SecurityBlockRuleDoc.getInstances({ enabled: true }); + } +} diff --git a/ts/db/documents/classes.security-policy-audit.doc.ts b/ts/db/documents/classes.security-policy-audit.doc.ts new file mode 100644 index 0000000..1791dce --- /dev/null +++ b/ts/db/documents/classes.security-policy-audit.doc.ts @@ -0,0 +1,33 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc implements ISecurityPolicyAuditEvent { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id!: string; + + @plugins.smartdata.svDb() + public action!: string; + + @plugins.smartdata.svDb() + public actor!: string; + + @plugins.smartdata.svDb() + public details!: Record; + + @plugins.smartdata.svDb() + public createdAt: number = Date.now(); + + constructor() { + super(); + } + + public static async findRecent(limit = 100): Promise { + const docs = await SecurityPolicyAuditDoc.getInstances({}); + return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); + } +} diff --git a/ts/db/documents/index.ts b/ts/db/documents/index.ts index 8e7c6b0..4a1914f 100644 --- a/ts/db/documents/index.ts +++ b/ts/db/documents/index.ts @@ -1,6 +1,9 @@ // Cached/TTL document classes export * from './classes.cached.email.js'; export * from './classes.cached.ip.reputation.js'; +export * from './classes.ip-intelligence.doc.js'; +export * from './classes.security-block-rule.doc.js'; +export * from './classes.security-policy-audit.doc.js'; // Config document classes export * from './classes.route.doc.js'; diff --git a/ts/monitoring/classes.metricsmanager.ts b/ts/monitoring/classes.metricsmanager.ts index e5da11c..eadb8d8 100644 --- a/ts/monitoring/classes.metricsmanager.ts +++ b/ts/monitoring/classes.metricsmanager.ts @@ -725,6 +725,8 @@ export class MetricsManager { .slice(0, 10) .map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut })); + void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]); + // Build domain activity using per-IP domain request counts from Rust engine const connectionsByRoute = proxyMetrics.connections.byRoute(); const throughputByRoute = proxyMetrics.throughput.byRoute(); diff --git a/ts/opsserver/handlers/security.handler.ts b/ts/opsserver/handlers/security.handler.ts index 43d82e8..5b834be 100644 --- a/ts/opsserver/handlers/security.handler.ts +++ b/ts/opsserver/handlers/security.handler.ts @@ -157,6 +157,75 @@ export class SecurityHandler { } ) ); + + router.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'listSecurityBlockRules', + async () => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + return { rules: manager ? await manager.listBlockRules() : [] }; + }, + ), + ); + + router.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'listIpIntelligence', + async () => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + return { records: manager ? await manager.listIpIntelligence() : [] }; + }, + ), + ); + + const adminRouter = this.opsServerRef.adminRouter; + + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createSecurityBlockRule', + async (dataArg) => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + if (!manager) return { success: false, message: 'Security policy manager not initialized' }; + const rule = await manager.createBlockRule({ + type: dataArg.type, + value: dataArg.value, + matchMode: dataArg.matchMode, + reason: dataArg.reason, + enabled: dataArg.enabled, + }, dataArg.identity.userId); + return { success: true, rule }; + }, + ), + ); + + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateSecurityBlockRule', + async (dataArg) => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + if (!manager) return { success: false, message: 'Security policy manager not initialized' }; + const rule = await manager.updateBlockRule(dataArg.id, { + value: dataArg.value, + matchMode: dataArg.matchMode, + reason: dataArg.reason, + enabled: dataArg.enabled, + }, dataArg.identity.userId); + return rule ? { success: true, rule } : { success: false, message: 'Rule not found' }; + }, + ), + ); + + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteSecurityBlockRule', + async (dataArg) => { + const manager = this.opsServerRef.dcRouterRef.securityPolicyManager; + if (!manager) return { success: false, message: 'Security policy manager not initialized' }; + const success = await manager.deleteBlockRule(dataArg.id, dataArg.identity.userId); + return { success, message: success ? undefined : 'Rule not found' }; + }, + ), + ); } private async collectSecurityMetrics(): Promise<{ diff --git a/ts/remoteingress/classes.remoteingress-manager.ts b/ts/remoteingress/classes.remoteingress-manager.ts index a30537b..e97fc99 100644 --- a/ts/remoteingress/classes.remoteingress-manager.ts +++ b/ts/remoteingress/classes.remoteingress-manager.ts @@ -2,6 +2,10 @@ import * as plugins from '../plugins.js'; import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import { RemoteIngressEdgeDoc } from '../db/index.js'; +interface IRemoteIngressFirewallConfig { + blockedIps?: string[]; +} + /** * Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array. */ @@ -31,6 +35,7 @@ function extractPorts(portRange: number | Array = new Map(); private routes: IDcRouterRouteConfig[] = []; + private firewallConfig?: IRemoteIngressFirewallConfig; constructor() { } @@ -69,6 +74,13 @@ export class RemoteIngressManager { this.routes = routes; } + /** + * Set the full desired firewall snapshot pushed to all edges. + */ + public setFirewallConfig(firewallConfig?: IRemoteIngressFirewallConfig): void { + this.firewallConfig = firewallConfig; + } + /** * Derive listen ports for an edge from routes tagged with remoteIngress.enabled. * When a route specifies edgeFilter, only edges whose id or tags match get that route's ports. @@ -305,8 +317,8 @@ export class RemoteIngressManager { * Get the list of allowed edges (enabled only) for the Rust hub. * Includes listenPortsUdp when routes with transport 'udp' or 'all' are present. */ - public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> { - const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = []; + public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> { + const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = []; for (const edge of this.edges.values()) { if (edge.enabled) { const listenPortsUdp = this.getEffectiveListenPortsUdp(edge); @@ -315,6 +327,7 @@ export class RemoteIngressManager { secret: edge.secret, listenPorts: this.getEffectiveListenPorts(edge), ...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}), + ...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}), }); } } diff --git a/ts/security/classes.security-policy-manager.ts b/ts/security/classes.security-policy-manager.ts new file mode 100644 index 0000000..115b9b6 --- /dev/null +++ b/ts/security/classes.security-policy-manager.ts @@ -0,0 +1,315 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js'; +import type { + IIpIntelligenceRecord, + ISecurityBlockRule, + ISecurityCompiledPolicy, + TSecurityBlockRuleMatchMode, + TSecurityBlockRuleType, +} from '../../ts_interfaces/data/security-policy.js'; + +export interface ISecurityPolicyManagerOptions { + intelligenceRefreshMs?: number; + onPolicyChanged?: () => void | Promise; +} + +export interface IRemoteIngressFirewallSnapshot { + blockedIps?: string[]; +} + +export class SecurityPolicyManager { + private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({ + cacheTtl: 24 * 60 * 60 * 1000, + }); + private readonly intelligenceRefreshMs: number; + private readonly inFlightObservations = new Set(); + private readonly onPolicyChanged?: () => void | Promise; + + constructor(options: ISecurityPolicyManagerOptions = {}) { + this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000; + this.onPolicyChanged = options.onPolicyChanged; + } + + public async start(): Promise { + logger.log('info', 'SecurityPolicyManager started'); + } + + public async stop(): Promise { + await this.smartNetwork.stop(); + } + + public async observeIps(ips: string[]): Promise { + const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])]; + await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip))); + } + + public async observeIp(ipAddress: string): Promise { + const ip = this.normalizeIp(ipAddress); + if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) { + return; + } + + this.inFlightObservations.add(ip); + try { + const now = Date.now(); + let doc = await IpIntelligenceDoc.findByIp(ip); + if (doc && now - doc.updatedAt < this.intelligenceRefreshMs) { + if (now - doc.lastSeenAt > 60_000) { + doc.lastSeenAt = now; + doc.seenCount = (doc.seenCount || 0) + 1; + await doc.save(); + } + return; + } + + const intelligence = await this.smartNetwork.getIpIntelligence(ip); + if (!doc) { + doc = new IpIntelligenceDoc(); + doc.ipAddress = ip; + doc.firstSeenAt = now; + } + Object.assign(doc, intelligence); + doc.lastSeenAt = now; + doc.updatedAt = now; + doc.seenCount = (doc.seenCount || 0) + 1; + await doc.save(); + + if (await this.matchesAnyReactiveRule(doc)) { + await this.notifyPolicyChanged(); + } + } catch (err) { + logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`); + } finally { + this.inFlightObservations.delete(ip); + } + } + + public async listBlockRules(): Promise { + return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc)); + } + + public async listIpIntelligence(): Promise { + return (await IpIntelligenceDoc.findAll()).map((doc) => ({ + ipAddress: doc.ipAddress, + asn: doc.asn, + asnOrg: doc.asnOrg, + registrantOrg: doc.registrantOrg, + registrantCountry: doc.registrantCountry, + networkRange: doc.networkRange, + abuseContact: doc.abuseContact, + country: doc.country, + countryCode: doc.countryCode, + city: doc.city, + latitude: doc.latitude, + longitude: doc.longitude, + accuracyRadius: doc.accuracyRadius, + timezone: doc.timezone, + firstSeenAt: doc.firstSeenAt, + lastSeenAt: doc.lastSeenAt, + updatedAt: doc.updatedAt, + seenCount: doc.seenCount, + })); + } + + public async createBlockRule(input: { + type: TSecurityBlockRuleType; + value: string; + matchMode?: TSecurityBlockRuleMatchMode; + reason?: string; + enabled?: boolean; + }, actor = 'system'): Promise { + const now = Date.now(); + const doc = new SecurityBlockRuleDoc(); + doc.id = plugins.uuid.v4(); + doc.type = input.type; + doc.value = input.value.trim(); + doc.matchMode = input.matchMode; + doc.reason = input.reason; + doc.enabled = input.enabled ?? true; + doc.createdAt = now; + doc.updatedAt = now; + doc.createdBy = actor; + await doc.save(); + await this.writeAudit('createBlockRule', actor, { rule: this.ruleFromDoc(doc) }); + await this.notifyPolicyChanged(); + return this.ruleFromDoc(doc); + } + + public async updateBlockRule(id: string, patch: Partial>, actor = 'system'): Promise { + const doc = await SecurityBlockRuleDoc.findById(id); + if (!doc) { + return null; + } + if (patch.value !== undefined) doc.value = patch.value.trim(); + if (patch.matchMode !== undefined) doc.matchMode = patch.matchMode; + if (patch.reason !== undefined) doc.reason = patch.reason; + if (patch.enabled !== undefined) doc.enabled = patch.enabled; + doc.updatedAt = Date.now(); + await doc.save(); + await this.writeAudit('updateBlockRule', actor, { id, patch }); + await this.notifyPolicyChanged(); + return this.ruleFromDoc(doc); + } + + public async deleteBlockRule(id: string, actor = 'system'): Promise { + const doc = await SecurityBlockRuleDoc.findById(id); + if (!doc) { + return false; + } + await doc.delete(); + await this.writeAudit('deleteBlockRule', actor, { id }); + await this.notifyPolicyChanged(); + return true; + } + + public async compilePolicy(): Promise { + const rules = await SecurityBlockRuleDoc.findEnabled(); + const intelligenceDocs = await IpIntelligenceDoc.findAll(); + const blockedIps = new Set(); + const blockedCidrs = new Set(); + + for (const rule of rules) { + const normalizedValue = rule.value.trim(); + if (!normalizedValue) continue; + + if (rule.type === 'ip') { + const ip = this.normalizeIp(normalizedValue); + if (ip && plugins.net.isIP(ip)) blockedIps.add(ip); + continue; + } + + if (rule.type === 'cidr') { + const cidr = this.normalizeCidr(normalizedValue); + if (cidr) blockedCidrs.add(cidr); + continue; + } + + for (const doc of intelligenceDocs) { + if (!this.ruleMatchesIntelligence(rule, doc)) continue; + const cidr = this.normalizeCidr(doc.networkRange || ''); + if (cidr) { + blockedCidrs.add(cidr); + } else if (this.normalizeIp(doc.ipAddress)) { + blockedIps.add(this.normalizeIp(doc.ipAddress)!); + } + } + } + + return { + blockedIps: [...blockedIps].sort(), + blockedCidrs: [...blockedCidrs].sort(), + }; + } + + public async compileSmartProxyPolicy(): Promise { + return await this.compilePolicy(); + } + + public async compileRemoteIngressFirewall(): Promise { + const policy = await this.compilePolicy(); + const blockedIps = [ + ...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4), + ...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4), + ]; + return blockedIps.length > 0 ? { blockedIps } : undefined; + } + + private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise { + const rules = await SecurityBlockRuleDoc.findEnabled(); + return rules.some((rule) => rule.type === 'asn' || rule.type === 'organization' + ? this.ruleMatchesIntelligence(rule, doc) + : false); + } + + private ruleMatchesIntelligence(rule: SecurityBlockRuleDoc, doc: IpIntelligenceDoc): boolean { + const value = rule.value.trim().toLowerCase(); + if (!value) return false; + + if (rule.type === 'asn') { + return String(doc.asn ?? '') === value.replace(/^as/i, ''); + } + + if (rule.type === 'organization') { + const candidates = [doc.asnOrg, doc.registrantOrg] + .filter(Boolean) + .map((candidate) => candidate!.toLowerCase()); + if (rule.matchMode === 'exact') { + return candidates.some((candidate) => candidate === value); + } + return candidates.some((candidate) => candidate.includes(value)); + } + + return false; + } + + private normalizeIp(ipAddress: string): string | undefined { + const ip = ipAddress.trim(); + if (ip.startsWith('::ffff:')) { + return ip.slice('::ffff:'.length); + } + return plugins.net.isIP(ip) ? ip : undefined; + } + + private normalizeCidr(value: string): string | undefined { + const [rawIp, rawPrefix] = value.trim().split('/'); + if (!rawIp || !rawPrefix) return undefined; + const ip = this.normalizeIp(rawIp); + if (!ip) return undefined; + const prefix = Number(rawPrefix); + const maxPrefix = plugins.net.isIP(ip) === 4 ? 32 : 128; + if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) return undefined; + return `${ip}/${prefix}`; + } + + private isPublicIp(ip: string): boolean { + const family = plugins.net.isIP(ip); + if (family === 4) { + const parts = ip.split('.').map((part) => Number(part)); + const [a, b] = parts; + if (a === 10 || a === 127 || a === 0 || a >= 224) return false; + if (a === 100 && b >= 64 && b <= 127) return false; + if (a === 169 && b === 254) return false; + if (a === 172 && b >= 16 && b <= 31) return false; + if (a === 192 && b === 168) return false; + return true; + } + if (family === 6) { + const lower = ip.toLowerCase(); + if (lower === '::1' || lower === '::') return false; + if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return false; + return true; + } + return false; + } + + private ruleFromDoc(doc: SecurityBlockRuleDoc): ISecurityBlockRule { + return { + id: doc.id, + type: doc.type, + value: doc.value, + matchMode: doc.matchMode, + enabled: doc.enabled, + reason: doc.reason, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + createdBy: doc.createdBy, + }; + } + + private async writeAudit(action: string, actor: string, details: Record): Promise { + const doc = new SecurityPolicyAuditDoc(); + doc.id = plugins.uuid.v4(); + doc.action = action; + doc.actor = actor; + doc.details = details; + doc.createdAt = Date.now(); + await doc.save(); + } + + private async notifyPolicyChanged(): Promise { + if (this.onPolicyChanged) { + await this.onPolicyChanged(); + } + } +} diff --git a/ts/security/index.ts b/ts/security/index.ts index 2f99b6b..253b131 100644 --- a/ts/security/index.ts +++ b/ts/security/index.ts @@ -18,4 +18,10 @@ export { ThreatCategory, type IScanResult, type IContentScannerOptions -} from './classes.contentscanner.js'; \ No newline at end of file +} from './classes.contentscanner.js'; + +export { + SecurityPolicyManager, + type ISecurityPolicyManagerOptions, + type IRemoteIngressFirewallSnapshot, +} from './classes.security-policy-manager.js'; diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 6cd0189..a3349bc 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -8,4 +8,5 @@ export * from './dns-provider.js'; export * from './domain.js'; export * from './dns-record.js'; export * from './acme-config.js'; -export * from './email-domain.js'; \ No newline at end of file +export * from './email-domain.js'; +export * from './security-policy.js'; diff --git a/ts_interfaces/data/security-policy.ts b/ts_interfaces/data/security-policy.ts new file mode 100644 index 0000000..70435e5 --- /dev/null +++ b/ts_interfaces/data/security-policy.ts @@ -0,0 +1,37 @@ +import type { IIpIntelligenceResult } from '@push.rocks/smartnetwork'; + +export type TSecurityBlockRuleType = 'ip' | 'cidr' | 'asn' | 'organization'; +export type TSecurityBlockRuleMatchMode = 'exact' | 'contains'; + +export interface IIpIntelligenceRecord extends IIpIntelligenceResult { + ipAddress: string; + firstSeenAt: number; + lastSeenAt: number; + updatedAt: number; + seenCount: number; +} + +export interface ISecurityBlockRule { + id: string; + type: TSecurityBlockRuleType; + value: string; + matchMode?: TSecurityBlockRuleMatchMode; + enabled: boolean; + reason?: string; + createdAt: number; + updatedAt: number; + createdBy: string; +} + +export interface ISecurityCompiledPolicy { + blockedIps: string[]; + blockedCidrs: string[]; +} + +export interface ISecurityPolicyAuditEvent { + id: string; + action: string; + actor: string; + details: Record; + createdAt: number; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index a180f3e..bc64d91 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -18,4 +18,5 @@ export * from './dns-providers.js'; export * from './domains.js'; export * from './dns-records.js'; export * from './acme-config.js'; -export * from './email-domains.js'; \ No newline at end of file +export * from './email-domains.js'; +export * from './security-policy.js'; diff --git a/ts_interfaces/requests/security-policy.ts b/ts_interfaces/requests/security-policy.ts new file mode 100644 index 0000000..015615f --- /dev/null +++ b/ts_interfaces/requests/security-policy.ts @@ -0,0 +1,89 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { + IIpIntelligenceRecord, + ISecurityBlockRule, + TSecurityBlockRuleMatchMode, + TSecurityBlockRuleType, +} from '../data/security-policy.js'; + +export interface IReq_ListSecurityBlockRules extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ListSecurityBlockRules +> { + method: 'listSecurityBlockRules'; + request: { + identity: authInterfaces.IIdentity; + }; + response: { + rules: ISecurityBlockRule[]; + }; +} + +export interface IReq_CreateSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateSecurityBlockRule +> { + method: 'createSecurityBlockRule'; + request: { + identity: authInterfaces.IIdentity; + type: TSecurityBlockRuleType; + value: string; + matchMode?: TSecurityBlockRuleMatchMode; + reason?: string; + enabled?: boolean; + }; + response: { + success: boolean; + rule?: ISecurityBlockRule; + message?: string; + }; +} + +export interface IReq_UpdateSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateSecurityBlockRule +> { + method: 'updateSecurityBlockRule'; + request: { + identity: authInterfaces.IIdentity; + id: string; + value?: string; + matchMode?: TSecurityBlockRuleMatchMode; + reason?: string; + enabled?: boolean; + }; + response: { + success: boolean; + rule?: ISecurityBlockRule; + message?: string; + }; +} + +export interface IReq_DeleteSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteSecurityBlockRule +> { + method: 'deleteSecurityBlockRule'; + request: { + identity: authInterfaces.IIdentity; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ListIpIntelligence +> { + method: 'listIpIntelligence'; + request: { + identity: authInterfaces.IIdentity; + }; + response: { + records: IIpIntelligenceRecord[]; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 3b0b8c4..c73e566 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.22.0', + version: '13.23.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' }