Compare commits

..

2 Commits

Author SHA1 Message Date
jkunz df9cc3e49b v13.25.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 20:49:57 +00:00
jkunz 7f3ab2499d feat(security): compile network ranges and CIDR arrays into edge firewall policies 2026-04-26 20:49:57 +00:00
8 changed files with 242 additions and 20 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-04-26 - 13.25.0 - feat(security)
compile network ranges and CIDR arrays into edge firewall policies
- add support for storing intelligence network CIDR arrays alongside single network ranges
- convert start-end IPv4 ranges into CIDR blocks when compiling security policies
- always return an explicit remote ingress firewall snapshot with a blockedIps array
- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots
## 2026-04-26 - 13.24.0 - feat(security) ## 2026-04-26 - 13.24.0 - feat(security)
add security policy management and IP intelligence operations to the ops UI add security policy management and IP intelligence operations to the ops UI
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "13.24.0", "version": "13.25.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -51,7 +51,7 @@
"@push.rocks/smartmetrics": "^3.0.3", "@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.2.0", "@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.3", "@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.6.0", "@push.rocks/smartnetwork": "^4.7.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.9.0", "@push.rocks/smartproxy": "^27.9.0",
+8 -8
View File
@@ -72,8 +72,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.6.0 specifier: ^4.7.0
version: 4.6.0 version: 4.7.0
'@push.rocks/smartpath': '@push.rocks/smartpath':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
@@ -1257,8 +1257,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.6.0': '@push.rocks/smartnetwork@4.7.0':
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==} resolution: {integrity: sha512-WZ46pJlklDRcw1AqkyyBhmGSNSK3i7IYM9D9vcVJOUhlLmgUSai8o1NbpWlb7HvOkp1IhQ7iZeuJV2JiWLtl1g==}
'@push.rocks/smartnftables@1.1.0': '@push.rocks/smartnftables@1.1.0':
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==} resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
@@ -5163,7 +5163,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.0 '@push.rocks/smartjson': 6.0.0
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7) '@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
'@push.rocks/smartnetwork': 4.6.0 '@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1 '@push.rocks/smartrequest': 5.0.1
@@ -5966,7 +5966,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 7.9.0 '@push.rocks/smartdns': 7.9.0
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.6.0 '@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartstring': 4.1.0 '@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.2.3 '@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
@@ -6433,7 +6433,7 @@ snapshots:
dependencies: dependencies:
handlebars: 4.7.9 handlebars: 4.7.9
'@push.rocks/smartnetwork@4.6.0': '@push.rocks/smartnetwork@4.7.0':
dependencies: dependencies:
'@push.rocks/smartdns': 7.9.0 '@push.rocks/smartdns': 7.9.0
'@push.rocks/smartrust': 1.3.2 '@push.rocks/smartrust': 1.3.2
@@ -6499,7 +6499,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.5.0 '@push.rocks/smartfs': 1.5.0
'@push.rocks/smartjimp': 1.2.0 '@push.rocks/smartjimp': 1.2.0
'@push.rocks/smartnetwork': 4.6.0 '@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2) '@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
+129
View File
@@ -0,0 +1,129 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js';
import { SecurityPolicyManager } from '../ts/security/index.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const rule of await SecurityBlockRuleDoc.findAll()) {
await rule.delete();
}
for (const record of await IpIntelligenceDoc.findAll()) {
await record.delete();
}
for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) {
await event.delete();
}
};
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
await manager.createBlockRule({
type: 'cidr',
value: '203.0.113.0 - 203.0.113.255',
reason: 'test range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']);
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall.blockedIps).toEqual(['203.0.113.0/24']);
});
tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.23';
intelligenceDoc.asn = 64500;
intelligenceDoc.asnOrg = 'Example Network';
intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127';
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64500',
reason: 'test asn range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']);
});
tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.130';
intelligenceDoc.asn = 64501;
intelligenceDoc.asnOrg = 'Example Split Network';
intelligenceDoc.networkRange = null;
intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24'];
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64501',
reason: 'test asn cidr array',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']);
});
tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall).toEqual({ blockedIps: [] });
});
tap.test('cleanup security policy test db', async () => {
const dbHandle = await testDbPromise;
await clearTestState();
await dbHandle.cleanup();
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.24.0', version: '13.25.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }
@@ -25,6 +25,9 @@ export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntell
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public networkRange: string | null = null; public networkRange: string | null = null;
@plugins.smartdata.svDb()
public networkCidrs: string[] | null = null;
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public abuseContact: string | null = null; public abuseContact: string | null = null;
+90 -8
View File
@@ -16,7 +16,7 @@ export interface ISecurityPolicyManagerOptions {
} }
export interface IRemoteIngressFirewallSnapshot { export interface IRemoteIngressFirewallSnapshot {
blockedIps?: string[]; blockedIps: string[];
} }
export class SecurityPolicyManager { export class SecurityPolicyManager {
@@ -122,6 +122,7 @@ export class SecurityPolicyManager {
registrantOrg: doc.registrantOrg, registrantOrg: doc.registrantOrg,
registrantCountry: doc.registrantCountry, registrantCountry: doc.registrantCountry,
networkRange: doc.networkRange, networkRange: doc.networkRange,
networkCidrs: doc.networkCidrs,
abuseContact: doc.abuseContact, abuseContact: doc.abuseContact,
country: doc.country, country: doc.country,
countryCode: doc.countryCode, countryCode: doc.countryCode,
@@ -205,16 +206,22 @@ export class SecurityPolicyManager {
} }
if (rule.type === 'cidr') { if (rule.type === 'cidr') {
const cidr = this.normalizeCidr(normalizedValue); for (const cidr of this.normalizeNetworkEntries(normalizedValue)) {
if (cidr) blockedCidrs.add(cidr); blockedCidrs.add(cidr);
}
continue; continue;
} }
for (const doc of intelligenceDocs) { for (const doc of intelligenceDocs) {
if (!this.ruleMatchesIntelligence(rule, doc)) continue; if (!this.ruleMatchesIntelligence(rule, doc)) continue;
const cidr = this.normalizeCidr(doc.networkRange || ''); const networkEntries = this.normalizeNetworkEntryList([
if (cidr) { ...(doc.networkCidrs || []),
blockedCidrs.add(cidr); doc.networkRange,
]);
if (networkEntries.length > 0) {
for (const cidr of networkEntries) {
blockedCidrs.add(cidr);
}
} else if (this.normalizeIp(doc.ipAddress)) { } else if (this.normalizeIp(doc.ipAddress)) {
blockedIps.add(this.normalizeIp(doc.ipAddress)!); blockedIps.add(this.normalizeIp(doc.ipAddress)!);
} }
@@ -231,13 +238,13 @@ export class SecurityPolicyManager {
return await this.compilePolicy(); return await this.compilePolicy();
} }
public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot | undefined> { public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot> {
const policy = await this.compilePolicy(); const policy = await this.compilePolicy();
const blockedIps = [ const blockedIps = [
...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4), ...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4),
...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4), ...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4),
]; ];
return blockedIps.length > 0 ? { blockedIps } : undefined; return { blockedIps };
} }
private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> { private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> {
@@ -287,6 +294,81 @@ export class SecurityPolicyManager {
return `${ip}/${prefix}`; return `${ip}/${prefix}`;
} }
private normalizeNetworkEntries(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const cidr = this.normalizeCidr(trimmed);
if (cidr) return [cidr];
const rangeParts = trimmed.split(/\s+-\s+/);
if (rangeParts.length === 2) {
return this.ipv4RangeToCidrs(rangeParts[0], rangeParts[1]);
}
return [];
}
private normalizeNetworkEntryList(values: Array<string | null | undefined>): string[] {
const cidrs = new Set<string>();
for (const value of values) {
if (!value) continue;
for (const entry of value.split(',').map((part) => part.trim()).filter(Boolean)) {
for (const cidr of this.normalizeNetworkEntries(entry)) {
cidrs.add(cidr);
}
}
}
return [...cidrs];
}
private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
const start = this.ipv4ToBigInt(startIp);
const end = this.ipv4ToBigInt(endIp);
if (start === undefined || end === undefined || start > end) return [];
const cidrs: string[] = [];
let current = start;
while (current <= end) {
let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
const remaining = end - current + 1n;
while (maxBlockSize > remaining) {
maxBlockSize = maxBlockSize / 2n;
}
const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
current += maxBlockSize;
}
return cidrs;
}
private ipv4ToBigInt(ip: string): bigint | undefined {
const normalized = this.normalizeIp(ip);
if (!normalized || plugins.net.isIP(normalized) !== 4) return undefined;
return normalized
.split('.')
.reduce((sum, part) => (sum * 256n) + BigInt(Number(part)), 0n);
}
private numberToIpv4(value: bigint): string {
return [
Number((value >> 24n) & 255n),
Number((value >> 16n) & 255n),
Number((value >> 8n) & 255n),
Number(value & 255n),
].join('.');
}
private powerOfTwoExponent(value: bigint): number {
let exponent = 0;
let remaining = value;
while (remaining > 1n) {
remaining >>= 1n;
exponent++;
}
return exponent;
}
private isPublicIp(ip: string): boolean { private isPublicIp(ip: string): boolean {
const family = plugins.net.isIP(ip); const family = plugins.net.isIP(ip);
if (family === 4) { if (family === 4) {
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.24.0', version: '13.25.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }