diff --git a/changelog.md b/changelog.md index 01d3f77..d9bfcb0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-26 - 1.2.0 - feat(firewall) +add IP set blocking convenience API with CIDR interval support and optional rule comments + +- introduces firewall.blockIPSet() to create a set, populate it with IPs or CIDR ranges, and apply a single drop rule +- adds interval set support so CIDR entries can be stored in nftables sets +- supports optional comments on generated IP set match rules +- adds forced cleanup support and allows table existence checks without requiring prior initialization + ## 2026-03-30 - 1.1.0 - feat(nft) add source IP filtering for DNAT rules and expose table existence checks diff --git a/test/test.rulebuilder-firewall.ts b/test/test.rulebuilder-firewall.ts index 2e013c4..4a267bc 100644 --- a/test/test.rulebuilder-firewall.ts +++ b/test/test.rulebuilder-firewall.ts @@ -137,4 +137,16 @@ tap.test('should build IP set match rule', async () => { expect(cmds[0]).toInclude('drop'); }); +tap.test('should build IP set match rule with comment', async () => { + const cmds = buildIPSetMatchRule('mytable', 'ip', { + setName: 'blocklist', + direction: 'input', + matchField: 'saddr', + action: 'drop', + comment: 'managed blocklist', + }); + + expect(cmds[0]).toInclude('comment "managed blocklist"'); +}); + export default tap.start(); diff --git a/test/test.smartnftables.ts b/test/test.smartnftables.ts index c8ef19d..67bafe4 100644 --- a/test/test.smartnftables.ts +++ b/test/test.smartnftables.ts @@ -97,6 +97,24 @@ tap.test('should handle blockIP convenience method', async () => { await nft.cleanup(); }); +tap.test('should handle blockIPSet convenience method', async () => { + const nft = new SmartNftables({ tableName: 'test' }); + await nft.initialize(); + + await nft.firewall.blockIPSet('bad-actors', { + ips: ['1.2.3.4', '5.6.0.0/16'], + setName: 'blocked_ipv4', + comment: 'test blocklist', + }); + + const status = nft.status(); + const group = status.groups['fw:blockset:bad-actors']; + expect(group).toBeDefined(); + expect(group.ruleCount).toEqual(3); // create set, add elements, match rule + + await nft.cleanup({ force: true }); +}); + tap.test('should handle stateful tracking convenience', async () => { const nft = new SmartNftables({ tableName: 'test' }); await nft.initialize(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index d6ec9c6..01234a9 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartnftables', - version: '1.1.0', + version: '1.2.0', description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.' } diff --git a/ts/index.ts b/ts/index.ts index a427e6a..e875b1e 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -52,7 +52,9 @@ export type { INftRateLimitRule, INftConnectionRateRule, INftFirewallRule, + INftIPSetBlockOptions, INftIPSetConfig, + INftCleanupOptions, INftRuleGroup, ISmartNftablesOptions, INftStatus, diff --git a/ts/nft.manager.firewall.ts b/ts/nft.manager.firewall.ts index 3827080..b3a378c 100644 --- a/ts/nft.manager.firewall.ts +++ b/ts/nft.manager.firewall.ts @@ -1,5 +1,5 @@ import type { SmartNftables } from './nft.manager.js'; -import type { INftFirewallRule, INftIPSetConfig } from './nft.types.js'; +import type { INftFirewallRule, INftIPSetBlockOptions, INftIPSetConfig } from './nft.types.js'; import { buildFirewallRule, buildIPSetCreate, @@ -82,6 +82,41 @@ export class FirewallManager { }); } + /** + * Convenience: block many IPs or CIDR subnets using one nftables set and + * one matching drop rule. This is substantially cheaper than one rule per IP. + */ + public async blockIPSet(groupId: string, options: INftIPSetBlockOptions): Promise { + const ips = [...new Set(options.ips)].filter(Boolean).sort(); + if (ips.length === 0) { + return; + } + + await this.parent.ensureFilterChains(); + + const setName = options.setName ?? `${groupId.replace(/[^a-zA-Z0-9_]/g, '_')}_set`; + const setType = options.type ?? (this.parent.family === 'ip6' ? 'ipv6_addr' : 'ipv4_addr'); + const needsIntervalSet = ips.some((ip) => ip.includes('/')); + const direction = options.direction ?? 'input'; + const commands = [ + ...buildIPSetCreate(this.parent.tableName, this.parent.family, { + name: setName, + type: setType, + elements: ips, + interval: needsIntervalSet, + }), + ...buildIPSetMatchRule(this.parent.tableName, this.parent.family, { + setName, + direction, + matchField: 'saddr', + action: 'drop', + comment: options.comment, + }), + ]; + + await this.parent.applyRuleGroup(`fw:blockset:${groupId}`, commands); + } + /** * Convenience: allow only specific IPs on a port. * Adds accept rules for each IP, then a drop rule for everything else on that port. diff --git a/ts/nft.manager.ts b/ts/nft.manager.ts index 954cabd..952de96 100644 --- a/ts/nft.manager.ts +++ b/ts/nft.manager.ts @@ -3,7 +3,7 @@ import { buildTableSetup, buildFilterChains, buildTableCleanup } from './nft.rul import { NatManager } from './nft.manager.nat.js'; import { FirewallManager } from './nft.manager.firewall.js'; import { RateLimitManager } from './nft.manager.ratelimit.js'; -import type { TNftFamily, INftRuleGroup, INftStatus, ISmartNftablesOptions } from './nft.types.js'; +import type { TNftFamily, INftCleanupOptions, INftRuleGroup, INftStatus, ISmartNftablesOptions } from './nft.types.js'; /** * SmartNftables — high-level facade for managing nftables rules. @@ -122,8 +122,8 @@ export class SmartNftables { /** * Delete the entire nftables table and clear all tracking. */ - public async cleanup(): Promise { - if (this.executor.isRoot() && this.initialized) { + public async cleanup(options: INftCleanupOptions = {}): Promise { + if (this.executor.isRoot() && (this.initialized || options.force)) { const commands = buildTableCleanup(this.tableName, this.family); await this.executor.execBatch(commands, { continueOnError: true }); } @@ -138,7 +138,7 @@ export class SmartNftables { * Returns false if not root, not initialized, or the table was removed externally. */ public async tableExists(): Promise { - if (!this.executor.isRoot() || !this.initialized) { + if (!this.executor.isRoot()) { return false; } try { diff --git a/ts/nft.rulebuilder.firewall.ts b/ts/nft.rulebuilder.firewall.ts index e0a26a1..50ebafc 100644 --- a/ts/nft.rulebuilder.firewall.ts +++ b/ts/nft.rulebuilder.firewall.ts @@ -84,10 +84,11 @@ export function buildIPSetCreate( config: INftIPSetConfig, ): string[] { const commands: string[] = []; + const flags = config.interval ? ' flags interval \\;' : ''; // Create the set commands.push( - `nft add set ${family} ${tableName} ${config.name} { type ${config.type} \\; }` + `nft add set ${family} ${tableName} ${config.name} { type ${config.type} \\;${flags} }` ); // Add initial elements if provided @@ -154,10 +155,12 @@ export function buildIPSetMatchRule( direction: 'input' | 'output' | 'forward'; matchField: 'saddr' | 'daddr'; action: 'accept' | 'drop' | 'reject'; + comment?: string; }, ): string[] { + const comment = options.comment ? ` comment "${options.comment}"` : ''; return [ - `nft add rule ${family} ${tableName} ${options.direction} ip ${options.matchField} @${options.setName} ${options.action}` + `nft add rule ${family} ${tableName} ${options.direction} ip ${options.matchField} @${options.setName}${comment} ${options.action}` ]; } diff --git a/ts/nft.types.ts b/ts/nft.types.ts index f0840fb..647054e 100644 --- a/ts/nft.types.ts +++ b/ts/nft.types.ts @@ -73,9 +73,29 @@ export interface INftIPSetConfig { name: string; type: 'ipv4_addr' | 'ipv6_addr' | 'inet_service'; elements?: string[]; + /** Enable interval sets so CIDR/range elements can be stored. */ + interval?: boolean; comment?: string; } +export interface INftIPSetBlockOptions { + /** Name of the nftables set to create and match against. */ + setName?: string; + /** IPs or CIDR ranges to add to the set. */ + ips: string[]; + /** Chain to apply the block rule to. Default: input. */ + direction?: 'input' | 'forward'; + /** Set type. Defaults to ipv4_addr for the default ip family. */ + type?: 'ipv4_addr' | 'ipv6_addr'; + /** Optional rule comment. */ + comment?: string; +} + +export interface INftCleanupOptions { + /** Delete the table even when this process did not initialize it. */ + force?: boolean; +} + // ─── Rule Group (tracking unit) ─────────────────────────────────── export interface INftRuleGroup { id: string;