feat(firewall): add IP set blocking convenience API with CIDR interval support and optional rule comments

This commit is contained in:
2026-04-26 15:05:50 +00:00
parent 75dacef68e
commit 6e7c0d90d8
9 changed files with 106 additions and 8 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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) ## 2026-03-30 - 1.1.0 - feat(nft)
add source IP filtering for DNAT rules and expose table existence checks add source IP filtering for DNAT rules and expose table existence checks
+12
View File
@@ -137,4 +137,16 @@ tap.test('should build IP set match rule', async () => {
expect(cmds[0]).toInclude('drop'); 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(); export default tap.start();
+18
View File
@@ -97,6 +97,24 @@ tap.test('should handle blockIP convenience method', async () => {
await nft.cleanup(); 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 () => { tap.test('should handle stateful tracking convenience', async () => {
const nft = new SmartNftables({ tableName: 'test' }); const nft = new SmartNftables({ tableName: 'test' });
await nft.initialize(); await nft.initialize();
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartnftables', 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.' description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
} }
+2
View File
@@ -52,7 +52,9 @@ export type {
INftRateLimitRule, INftRateLimitRule,
INftConnectionRateRule, INftConnectionRateRule,
INftFirewallRule, INftFirewallRule,
INftIPSetBlockOptions,
INftIPSetConfig, INftIPSetConfig,
INftCleanupOptions,
INftRuleGroup, INftRuleGroup,
ISmartNftablesOptions, ISmartNftablesOptions,
INftStatus, INftStatus,
+36 -1
View File
@@ -1,5 +1,5 @@
import type { SmartNftables } from './nft.manager.js'; 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 { import {
buildFirewallRule, buildFirewallRule,
buildIPSetCreate, 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<void> {
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. * Convenience: allow only specific IPs on a port.
* Adds accept rules for each IP, then a drop rule for everything else on that port. * Adds accept rules for each IP, then a drop rule for everything else on that port.
+4 -4
View File
@@ -3,7 +3,7 @@ import { buildTableSetup, buildFilterChains, buildTableCleanup } from './nft.rul
import { NatManager } from './nft.manager.nat.js'; import { NatManager } from './nft.manager.nat.js';
import { FirewallManager } from './nft.manager.firewall.js'; import { FirewallManager } from './nft.manager.firewall.js';
import { RateLimitManager } from './nft.manager.ratelimit.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. * SmartNftables — high-level facade for managing nftables rules.
@@ -122,8 +122,8 @@ export class SmartNftables {
/** /**
* Delete the entire nftables table and clear all tracking. * Delete the entire nftables table and clear all tracking.
*/ */
public async cleanup(): Promise<void> { public async cleanup(options: INftCleanupOptions = {}): Promise<void> {
if (this.executor.isRoot() && this.initialized) { if (this.executor.isRoot() && (this.initialized || options.force)) {
const commands = buildTableCleanup(this.tableName, this.family); const commands = buildTableCleanup(this.tableName, this.family);
await this.executor.execBatch(commands, { continueOnError: true }); 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. * Returns false if not root, not initialized, or the table was removed externally.
*/ */
public async tableExists(): Promise<boolean> { public async tableExists(): Promise<boolean> {
if (!this.executor.isRoot() || !this.initialized) { if (!this.executor.isRoot()) {
return false; return false;
} }
try { try {
+5 -2
View File
@@ -84,10 +84,11 @@ export function buildIPSetCreate(
config: INftIPSetConfig, config: INftIPSetConfig,
): string[] { ): string[] {
const commands: string[] = []; const commands: string[] = [];
const flags = config.interval ? ' flags interval \\;' : '';
// Create the set // Create the set
commands.push( 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 // Add initial elements if provided
@@ -154,10 +155,12 @@ export function buildIPSetMatchRule(
direction: 'input' | 'output' | 'forward'; direction: 'input' | 'output' | 'forward';
matchField: 'saddr' | 'daddr'; matchField: 'saddr' | 'daddr';
action: 'accept' | 'drop' | 'reject'; action: 'accept' | 'drop' | 'reject';
comment?: string;
}, },
): string[] { ): string[] {
const comment = options.comment ? ` comment "${options.comment}"` : '';
return [ 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}`
]; ];
} }
+20
View File
@@ -73,9 +73,29 @@ export interface INftIPSetConfig {
name: string; name: string;
type: 'ipv4_addr' | 'ipv6_addr' | 'inet_service'; type: 'ipv4_addr' | 'ipv6_addr' | 'inet_service';
elements?: string[]; elements?: string[];
/** Enable interval sets so CIDR/range elements can be stored. */
interval?: boolean;
comment?: string; 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) ─────────────────────────────────── // ─── Rule Group (tracking unit) ───────────────────────────────────
export interface INftRuleGroup { export interface INftRuleGroup {
id: string; id: string;