initial
This commit is contained in:
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartnftables',
|
||||
version: '1.0.0',
|
||||
description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
|
||||
}
|
||||
59
ts/index.ts
Normal file
59
ts/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Main facade
|
||||
export { SmartNftables } from './nft.manager.js';
|
||||
|
||||
// Sub-managers
|
||||
export { NatManager } from './nft.manager.nat.js';
|
||||
export { FirewallManager } from './nft.manager.firewall.js';
|
||||
export { RateLimitManager } from './nft.manager.ratelimit.js';
|
||||
|
||||
// Executor
|
||||
export { NftExecutor, NftNotRootError, NftCommandError } from './nft.executor.js';
|
||||
|
||||
// Rule builders (for advanced usage)
|
||||
export {
|
||||
buildTableSetup,
|
||||
buildFilterChains,
|
||||
buildTableCleanup,
|
||||
} from './nft.rulebuilder.table.js';
|
||||
|
||||
export {
|
||||
buildDnatRules,
|
||||
buildSnatRule,
|
||||
buildMasqueradeRule,
|
||||
} from './nft.rulebuilder.nat.js';
|
||||
|
||||
export {
|
||||
buildRateLimitRule,
|
||||
buildPerIpRateLimitRule,
|
||||
buildConnectionRateRule,
|
||||
} from './nft.rulebuilder.ratelimit.js';
|
||||
|
||||
export {
|
||||
buildFirewallRule,
|
||||
buildIPSetCreate,
|
||||
buildIPSetAddElements,
|
||||
buildIPSetRemoveElements,
|
||||
buildIPSetDelete,
|
||||
buildIPSetMatchRule,
|
||||
} from './nft.rulebuilder.firewall.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
TNftProtocol,
|
||||
TNftFamily,
|
||||
TNftChainHook,
|
||||
TNftChainType,
|
||||
TNftPolicy,
|
||||
TFirewallAction,
|
||||
TCtState,
|
||||
INftDnatRule,
|
||||
INftSnatRule,
|
||||
INftMasqueradeRule,
|
||||
INftRateLimitRule,
|
||||
INftConnectionRateRule,
|
||||
INftFirewallRule,
|
||||
INftIPSetConfig,
|
||||
INftRuleGroup,
|
||||
ISmartNftablesOptions,
|
||||
INftStatus,
|
||||
} from './nft.types.js';
|
||||
74
ts/nft.executor.ts
Normal file
74
ts/nft.executor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
const execFile = plugins.util.promisify(plugins.childProcess.execFile);
|
||||
|
||||
export class NftNotRootError extends Error {
|
||||
constructor() {
|
||||
super('Not running as root — nftables commands require root privileges');
|
||||
this.name = 'NftNotRootError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NftCommandError extends Error {
|
||||
public readonly command: string;
|
||||
public readonly stderr: string;
|
||||
|
||||
constructor(command: string, stderr: string) {
|
||||
super(`nft command failed: ${command} — ${stderr}`);
|
||||
this.name = 'NftCommandError';
|
||||
this.command = command;
|
||||
this.stderr = stderr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level executor for nft CLI commands.
|
||||
*/
|
||||
export class NftExecutor {
|
||||
private dryRun: boolean;
|
||||
|
||||
constructor(options?: { dryRun?: boolean }) {
|
||||
this.dryRun = options?.dryRun ?? false;
|
||||
}
|
||||
|
||||
/** Check if running as root (euid 0). */
|
||||
public isRoot(): boolean {
|
||||
return process.getuid?.() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single nft command.
|
||||
* The command may or may not start with "nft " — the prefix is stripped if present.
|
||||
* Returns stdout on success.
|
||||
*/
|
||||
public async exec(command: string): Promise<string> {
|
||||
if (this.dryRun) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Strip "nft " prefix if present
|
||||
const args = command.startsWith('nft ') ? command.slice(4) : command;
|
||||
|
||||
const { stdout } = await execFile('nft', args.split(/\s+/));
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple nft commands sequentially.
|
||||
* By default stops on first failure. Set continueOnError to keep going.
|
||||
*/
|
||||
public async execBatch(
|
||||
commands: string[],
|
||||
options?: { continueOnError?: boolean }
|
||||
): Promise<void> {
|
||||
for (const cmd of commands) {
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
} catch (err) {
|
||||
if (!options?.continueOnError) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
ts/nft.manager.firewall.ts
Normal file
148
ts/nft.manager.firewall.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { SmartNftables } from './nft.manager.js';
|
||||
import type { INftFirewallRule, INftIPSetConfig } from './nft.types.js';
|
||||
import {
|
||||
buildFirewallRule,
|
||||
buildIPSetCreate,
|
||||
buildIPSetAddElements,
|
||||
buildIPSetRemoveElements,
|
||||
buildIPSetDelete,
|
||||
buildIPSetMatchRule,
|
||||
} from './nft.rulebuilder.firewall.js';
|
||||
|
||||
/**
|
||||
* Manages firewall (filter) rules, IP sets, and convenience methods.
|
||||
*/
|
||||
export class FirewallManager {
|
||||
constructor(private parent: SmartNftables) {}
|
||||
|
||||
/**
|
||||
* Add a firewall rule (input/output/forward).
|
||||
*/
|
||||
public async addRule(groupId: string, rule: INftFirewallRule): Promise<void> {
|
||||
await this.parent.ensureFilterChains();
|
||||
const commands = buildFirewallRule(this.parent.tableName, this.parent.family, rule);
|
||||
await this.parent.applyRuleGroup(`fw:${groupId}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a firewall rule group.
|
||||
*/
|
||||
public async removeRule(groupId: string): Promise<void> {
|
||||
await this.parent.removeRuleGroup(`fw:${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a named IP set.
|
||||
*/
|
||||
public async createIPSet(config: INftIPSetConfig): Promise<void> {
|
||||
await this.parent.ensureInitialized();
|
||||
const commands = buildIPSetCreate(this.parent.tableName, this.parent.family, config);
|
||||
await this.parent.applyRuleGroup(`ipset:${config.name}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add elements to an existing IP set.
|
||||
*/
|
||||
public async addToIPSet(setName: string, elements: string[]): Promise<void> {
|
||||
const commands = buildIPSetAddElements(this.parent.tableName, this.parent.family, setName, elements);
|
||||
if (commands.length > 0) {
|
||||
await this.parent.executor.execBatch(commands);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove elements from an IP set.
|
||||
*/
|
||||
public async removeFromIPSet(setName: string, elements: string[]): Promise<void> {
|
||||
const commands = buildIPSetRemoveElements(this.parent.tableName, this.parent.family, setName, elements);
|
||||
if (commands.length > 0) {
|
||||
await this.parent.executor.execBatch(commands);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an IP set entirely.
|
||||
*/
|
||||
public async deleteIPSet(setName: string): Promise<void> {
|
||||
const commands = buildIPSetDelete(this.parent.tableName, this.parent.family, setName);
|
||||
await this.parent.executor.execBatch(commands);
|
||||
await this.parent.removeRuleGroup(`ipset:${setName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: block an IP or CIDR subnet.
|
||||
*/
|
||||
public async blockIP(ip: string, options?: { direction?: 'input' | 'forward' }): Promise<void> {
|
||||
const direction = options?.direction ?? 'input';
|
||||
const safeId = ip.replace(/[/.]/g, '_');
|
||||
await this.addRule(`block-${safeId}`, {
|
||||
direction,
|
||||
action: 'drop',
|
||||
sourceIP: ip,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: allow only specific IPs on a port.
|
||||
* Adds accept rules for each IP, then a drop rule for everything else on that port.
|
||||
*/
|
||||
public async allowOnlyIPs(
|
||||
groupId: string,
|
||||
ips: string[],
|
||||
port?: number,
|
||||
protocol?: 'tcp' | 'udp' | 'both',
|
||||
): Promise<void> {
|
||||
await this.parent.ensureFilterChains();
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const ip of ips) {
|
||||
const acceptCmds = buildFirewallRule(this.parent.tableName, this.parent.family, {
|
||||
direction: 'input',
|
||||
action: 'accept',
|
||||
sourceIP: ip,
|
||||
destPort: port,
|
||||
protocol,
|
||||
});
|
||||
commands.push(...acceptCmds);
|
||||
}
|
||||
|
||||
// Drop everything else on that port
|
||||
const dropCmds = buildFirewallRule(this.parent.tableName, this.parent.family, {
|
||||
direction: 'input',
|
||||
action: 'drop',
|
||||
destPort: port,
|
||||
protocol,
|
||||
});
|
||||
commands.push(...dropCmds);
|
||||
|
||||
await this.parent.applyRuleGroup(`fw:allowonly:${groupId}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: enable stateful connection tracking.
|
||||
* Allows established+related, drops invalid.
|
||||
*/
|
||||
public async enableStatefulTracking(chain?: 'input' | 'forward' | 'output'): Promise<void> {
|
||||
await this.parent.ensureFilterChains();
|
||||
const direction = chain ?? 'input';
|
||||
const commands: string[] = [];
|
||||
|
||||
// Allow established and related connections
|
||||
const allowCmds = buildFirewallRule(this.parent.tableName, this.parent.family, {
|
||||
direction,
|
||||
action: 'accept',
|
||||
ctStates: ['established', 'related'],
|
||||
});
|
||||
commands.push(...allowCmds);
|
||||
|
||||
// Drop invalid connections
|
||||
const dropCmds = buildFirewallRule(this.parent.tableName, this.parent.family, {
|
||||
direction,
|
||||
action: 'drop',
|
||||
ctStates: ['invalid'],
|
||||
});
|
||||
commands.push(...dropCmds);
|
||||
|
||||
await this.parent.applyRuleGroup(`fw:stateful:${direction}`, commands);
|
||||
}
|
||||
}
|
||||
76
ts/nft.manager.nat.ts
Normal file
76
ts/nft.manager.nat.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { SmartNftables } from './nft.manager.js';
|
||||
import type { INftDnatRule, INftSnatRule, INftMasqueradeRule, TNftProtocol } from './nft.types.js';
|
||||
import { buildDnatRules, buildSnatRule, buildMasqueradeRule } from './nft.rulebuilder.nat.js';
|
||||
|
||||
/**
|
||||
* Manages NAT (DNAT/SNAT/masquerade) rules.
|
||||
*/
|
||||
export class NatManager {
|
||||
constructor(private parent: SmartNftables) {}
|
||||
|
||||
/**
|
||||
* Add a port forwarding rule (DNAT + optional masquerade).
|
||||
*/
|
||||
public async addPortForwarding(groupId: string, rule: INftDnatRule): Promise<void> {
|
||||
const commands = buildDnatRules(this.parent.tableName, this.parent.family, rule);
|
||||
await this.parent.applyRuleGroup(`nat:${groupId}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a previously added port forwarding group.
|
||||
*/
|
||||
public async removePortForwarding(groupId: string): Promise<void> {
|
||||
await this.parent.removeRuleGroup(`nat:${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add SNAT (source NAT) rule.
|
||||
*/
|
||||
public async addSnat(groupId: string, rule: INftSnatRule): Promise<void> {
|
||||
const commands = buildSnatRule(this.parent.tableName, this.parent.family, rule);
|
||||
await this.parent.applyRuleGroup(`nat:snat:${groupId}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add masquerade rule for outgoing traffic.
|
||||
*/
|
||||
public async addMasquerade(groupId: string, rule: INftMasqueradeRule): Promise<void> {
|
||||
const commands = buildMasqueradeRule(this.parent.tableName, this.parent.family, rule);
|
||||
await this.parent.applyRuleGroup(`nat:masq:${groupId}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add port forwarding for a range of ports.
|
||||
* Maps sourceStart..sourceStart+count to targetStart..targetStart+count.
|
||||
*/
|
||||
public async addPortRange(
|
||||
groupId: string,
|
||||
sourceStart: number,
|
||||
sourceEnd: number,
|
||||
targetHost: string,
|
||||
targetStart: number,
|
||||
protocol?: TNftProtocol,
|
||||
): Promise<void> {
|
||||
const allCommands: string[] = [];
|
||||
const count = sourceEnd - sourceStart;
|
||||
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const commands = buildDnatRules(this.parent.tableName, this.parent.family, {
|
||||
sourcePort: sourceStart + i,
|
||||
targetHost,
|
||||
targetPort: targetStart + i,
|
||||
protocol,
|
||||
});
|
||||
allCommands.push(...commands);
|
||||
}
|
||||
|
||||
await this.parent.applyRuleGroup(`nat:range:${groupId}`, allCommands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a port range forwarding group.
|
||||
*/
|
||||
public async removePortRange(groupId: string): Promise<void> {
|
||||
await this.parent.removeRuleGroup(`nat:range:${groupId}`);
|
||||
}
|
||||
}
|
||||
43
ts/nft.manager.ratelimit.ts
Normal file
43
ts/nft.manager.ratelimit.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { SmartNftables } from './nft.manager.js';
|
||||
import type { INftRateLimitRule, INftConnectionRateRule } from './nft.types.js';
|
||||
import { buildRateLimitRule, buildConnectionRateRule } from './nft.rulebuilder.ratelimit.js';
|
||||
|
||||
/**
|
||||
* Manages rate limiting rules using nft meters and limit expressions.
|
||||
*/
|
||||
export class RateLimitManager {
|
||||
constructor(private parent: SmartNftables) {}
|
||||
|
||||
/**
|
||||
* Add a rate limit rule (global or per-IP).
|
||||
*/
|
||||
public async addRateLimit(groupId: string, rule: INftRateLimitRule): Promise<void> {
|
||||
await this.parent.ensureFilterChains();
|
||||
const commands = buildRateLimitRule(this.parent.tableName, this.parent.family, rule);
|
||||
await this.parent.applyRuleGroup(`ratelimit:${groupId}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a rate limit rule group.
|
||||
*/
|
||||
public async removeRateLimit(groupId: string): Promise<void> {
|
||||
await this.parent.removeRuleGroup(`ratelimit:${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new-connection rate limit rule.
|
||||
* Limits the rate of new TCP/UDP connections (ct state new).
|
||||
*/
|
||||
public async addConnectionRateLimit(groupId: string, rule: INftConnectionRateRule): Promise<void> {
|
||||
await this.parent.ensureFilterChains();
|
||||
const commands = buildConnectionRateRule(this.parent.tableName, this.parent.family, rule);
|
||||
await this.parent.applyRuleGroup(`connrate:${groupId}`, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a connection rate limit rule group.
|
||||
*/
|
||||
public async removeConnectionRateLimit(groupId: string): Promise<void> {
|
||||
await this.parent.removeRuleGroup(`connrate:${groupId}`);
|
||||
}
|
||||
}
|
||||
157
ts/nft.manager.ts
Normal file
157
ts/nft.manager.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { NftExecutor } from './nft.executor.js';
|
||||
import { buildTableSetup, buildFilterChains, buildTableCleanup } from './nft.rulebuilder.table.js';
|
||||
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';
|
||||
|
||||
/**
|
||||
* SmartNftables — high-level facade for managing nftables rules.
|
||||
*
|
||||
* Provides sub-managers for NAT, firewall, and rate limiting.
|
||||
* All rules are tracked in logical groups and can be removed individually or cleaned up entirely.
|
||||
*/
|
||||
export class SmartNftables {
|
||||
public readonly nat: NatManager;
|
||||
public readonly firewall: FirewallManager;
|
||||
public readonly rateLimit: RateLimitManager;
|
||||
|
||||
public readonly tableName: string;
|
||||
public readonly family: TNftFamily;
|
||||
public readonly executor: NftExecutor;
|
||||
|
||||
private initialized = false;
|
||||
private hasFilterChains = false;
|
||||
private warnedNonRoot = false;
|
||||
private ruleGroups: Map<string, INftRuleGroup> = new Map();
|
||||
|
||||
constructor(options?: ISmartNftablesOptions) {
|
||||
this.tableName = options?.tableName ?? 'smartnftables';
|
||||
this.family = options?.family ?? 'ip';
|
||||
this.executor = new NftExecutor({ dryRun: options?.dryRun });
|
||||
|
||||
this.nat = new NatManager(this);
|
||||
this.firewall = new FirewallManager(this);
|
||||
this.rateLimit = new RateLimitManager(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the nftables table and NAT chains. Idempotent.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (!this.executor.isRoot()) {
|
||||
if (!this.warnedNonRoot) {
|
||||
console.warn('smartnftables: not running as root. Rules are tracked but not applied to kernel.');
|
||||
this.warnedNonRoot = true;
|
||||
}
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = buildTableSetup(this.tableName, this.family);
|
||||
await this.executor.execBatch(commands);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure filter chains (input/forward/output) are created.
|
||||
* Called automatically when firewall or rate-limit rules are added.
|
||||
*/
|
||||
public async ensureFilterChains(): Promise<void> {
|
||||
if (this.hasFilterChains) return;
|
||||
await this.ensureInitialized();
|
||||
|
||||
if (this.executor.isRoot()) {
|
||||
const commands = buildFilterChains(this.tableName, this.family);
|
||||
await this.executor.execBatch(commands);
|
||||
}
|
||||
|
||||
this.hasFilterChains = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the table is initialized before applying rules.
|
||||
*/
|
||||
public async ensureInitialized(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a group of nft commands and track them under the given ID.
|
||||
*/
|
||||
public async applyRuleGroup(groupId: string, commands: string[]): Promise<void> {
|
||||
// Always track the group locally
|
||||
this.ruleGroups.set(groupId, {
|
||||
id: groupId,
|
||||
commands,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
if (!this.executor.isRoot()) {
|
||||
if (!this.warnedNonRoot) {
|
||||
console.warn('smartnftables: not running as root. Rules are tracked but not applied to kernel.');
|
||||
this.warnedNonRoot = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureInitialized();
|
||||
await this.executor.execBatch(commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tracked rule group. Removes from tracking.
|
||||
* Note: full kernel cleanup requires cleanup() — individual rule removal
|
||||
* would require handle-based tracking.
|
||||
*/
|
||||
public async removeRuleGroup(groupId: string): Promise<void> {
|
||||
this.ruleGroups.delete(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tracked rule group by ID.
|
||||
*/
|
||||
public getRuleGroup(groupId: string): INftRuleGroup | undefined {
|
||||
return this.ruleGroups.get(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entire nftables table and clear all tracking.
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
if (this.executor.isRoot() && this.initialized) {
|
||||
const commands = buildTableCleanup(this.tableName, this.family);
|
||||
await this.executor.execBatch(commands, { continueOnError: true });
|
||||
}
|
||||
|
||||
this.ruleGroups.clear();
|
||||
this.initialized = false;
|
||||
this.hasFilterChains = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status report of the managed nftables state.
|
||||
*/
|
||||
public status(): INftStatus {
|
||||
const groups: Record<string, { ruleCount: number; createdAt: number }> = {};
|
||||
for (const [id, group] of this.ruleGroups) {
|
||||
groups[id] = {
|
||||
ruleCount: group.commands.length,
|
||||
createdAt: group.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initialized: this.initialized,
|
||||
tableName: this.tableName,
|
||||
family: this.family,
|
||||
isRoot: this.executor.isRoot(),
|
||||
activeGroups: this.ruleGroups.size,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
}
|
||||
173
ts/nft.rulebuilder.firewall.ts
Normal file
173
ts/nft.rulebuilder.firewall.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { TNftFamily, INftFirewallRule, INftIPSetConfig } from './nft.types.js';
|
||||
|
||||
/**
|
||||
* Build an nft firewall rule for input/output/forward chains.
|
||||
*/
|
||||
export function buildFirewallRule(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
rule: INftFirewallRule,
|
||||
): string[] {
|
||||
const chain = rule.direction;
|
||||
const parts: string[] = [`nft add rule ${family} ${tableName} ${chain}`];
|
||||
|
||||
// Connection tracking states
|
||||
if (rule.ctStates && rule.ctStates.length > 0) {
|
||||
parts.push(`ct state { ${rule.ctStates.join(', ')} }`);
|
||||
}
|
||||
|
||||
// Protocol and port matching
|
||||
const protocols = expandProtocols(rule.protocol);
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const proto of protocols) {
|
||||
const ruleParts = [...parts];
|
||||
|
||||
if (rule.protocol) {
|
||||
ruleParts.push(proto);
|
||||
}
|
||||
|
||||
if (rule.sourceIP) {
|
||||
ruleParts.push(`ip saddr ${rule.sourceIP}`);
|
||||
}
|
||||
|
||||
if (rule.destIP) {
|
||||
ruleParts.push(`ip daddr ${rule.destIP}`);
|
||||
}
|
||||
|
||||
if (rule.sourcePort != null) {
|
||||
ruleParts.push(`${proto} sport ${rule.sourcePort}`);
|
||||
}
|
||||
|
||||
if (rule.destPort != null) {
|
||||
// If protocol wasn't explicitly set but we have a port, we need the protocol
|
||||
if (!rule.protocol) {
|
||||
ruleParts.push(`tcp dport ${rule.destPort}`);
|
||||
} else {
|
||||
ruleParts.push(`${proto} dport ${rule.destPort}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.comment) {
|
||||
ruleParts.push(`comment "${rule.comment}"`);
|
||||
}
|
||||
|
||||
ruleParts.push(rule.action);
|
||||
commands.push(ruleParts.join(' '));
|
||||
}
|
||||
|
||||
// If no protocol expansion needed (no protocol-specific fields)
|
||||
if (commands.length === 0) {
|
||||
const ruleParts = [...parts];
|
||||
if (rule.sourceIP) {
|
||||
ruleParts.push(`ip saddr ${rule.sourceIP}`);
|
||||
}
|
||||
if (rule.destIP) {
|
||||
ruleParts.push(`ip daddr ${rule.destIP}`);
|
||||
}
|
||||
if (rule.comment) {
|
||||
ruleParts.push(`comment "${rule.comment}"`);
|
||||
}
|
||||
ruleParts.push(rule.action);
|
||||
commands.push(ruleParts.join(' '));
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build commands to create an nft named set (IP set).
|
||||
*/
|
||||
export function buildIPSetCreate(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
config: INftIPSetConfig,
|
||||
): string[] {
|
||||
const commands: string[] = [];
|
||||
|
||||
// Create the set
|
||||
commands.push(
|
||||
`nft add set ${family} ${tableName} ${config.name} { type ${config.type} \\; }`
|
||||
);
|
||||
|
||||
// Add initial elements if provided
|
||||
if (config.elements && config.elements.length > 0) {
|
||||
commands.push(
|
||||
`nft add element ${family} ${tableName} ${config.name} { ${config.elements.join(', ')} }`
|
||||
);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command to add elements to an existing set.
|
||||
*/
|
||||
export function buildIPSetAddElements(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
setName: string,
|
||||
elements: string[],
|
||||
): string[] {
|
||||
if (elements.length === 0) return [];
|
||||
return [
|
||||
`nft add element ${family} ${tableName} ${setName} { ${elements.join(', ')} }`
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command to remove elements from a set.
|
||||
*/
|
||||
export function buildIPSetRemoveElements(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
setName: string,
|
||||
elements: string[],
|
||||
): string[] {
|
||||
if (elements.length === 0) return [];
|
||||
return [
|
||||
`nft delete element ${family} ${tableName} ${setName} { ${elements.join(', ')} }`
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command to delete an entire set.
|
||||
*/
|
||||
export function buildIPSetDelete(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
setName: string,
|
||||
): string[] {
|
||||
return [
|
||||
`nft delete set ${family} ${tableName} ${setName}`
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a rule that matches against a named set.
|
||||
*/
|
||||
export function buildIPSetMatchRule(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
options: {
|
||||
setName: string;
|
||||
direction: 'input' | 'output' | 'forward';
|
||||
matchField: 'saddr' | 'daddr';
|
||||
action: 'accept' | 'drop' | 'reject';
|
||||
},
|
||||
): string[] {
|
||||
return [
|
||||
`nft add rule ${family} ${tableName} ${options.direction} ip ${options.matchField} @${options.setName} ${options.action}`
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────
|
||||
|
||||
function expandProtocols(protocol?: 'tcp' | 'udp' | 'both'): string[] {
|
||||
if (!protocol) return [];
|
||||
switch (protocol) {
|
||||
case 'tcp': return ['tcp'];
|
||||
case 'udp': return ['udp'];
|
||||
case 'both': return ['tcp', 'udp'];
|
||||
}
|
||||
}
|
||||
82
ts/nft.rulebuilder.nat.ts
Normal file
82
ts/nft.rulebuilder.nat.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { TNftFamily, TNftProtocol, INftDnatRule, INftSnatRule, INftMasqueradeRule } from './nft.types.js';
|
||||
|
||||
/**
|
||||
* Expand a protocol spec into concrete protocol strings.
|
||||
*/
|
||||
function expandProtocols(protocol?: TNftProtocol): string[] {
|
||||
switch (protocol ?? 'tcp') {
|
||||
case 'tcp': return ['tcp'];
|
||||
case 'udp': return ['udp'];
|
||||
case 'both': return ['tcp', 'udp'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build DNAT rules for port forwarding.
|
||||
* Generates DNAT + optional masquerade for each protocol.
|
||||
* Direct port of Rust build_dnat_rule.
|
||||
*/
|
||||
export function buildDnatRules(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
rule: INftDnatRule,
|
||||
): string[] {
|
||||
const protocols = expandProtocols(rule.protocol);
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const proto of protocols) {
|
||||
// DNAT rule
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} prerouting ${proto} dport ${rule.sourcePort} dnat to ${rule.targetHost}:${rule.targetPort}`
|
||||
);
|
||||
|
||||
// Masquerade (SNAT) unless preserveSourceIP is set
|
||||
if (!rule.preserveSourceIP) {
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} postrouting ${proto} dport ${rule.targetPort} masquerade`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SNAT rule to rewrite source address.
|
||||
*/
|
||||
export function buildSnatRule(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
rule: INftSnatRule,
|
||||
): string[] {
|
||||
const protocols = expandProtocols(rule.protocol);
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const proto of protocols) {
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} postrouting ${proto} dport ${rule.targetPort} snat to ${rule.sourceAddress}`
|
||||
);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a masquerade rule for outgoing NAT.
|
||||
*/
|
||||
export function buildMasqueradeRule(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
rule: INftMasqueradeRule,
|
||||
): string[] {
|
||||
const protocols = expandProtocols(rule.protocol);
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const proto of protocols) {
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} postrouting ${proto} dport ${rule.targetPort} masquerade`
|
||||
);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
89
ts/nft.rulebuilder.ratelimit.ts
Normal file
89
ts/nft.rulebuilder.ratelimit.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { TNftFamily, INftRateLimitRule, INftConnectionRateRule } from './nft.types.js';
|
||||
|
||||
/**
|
||||
* Expand a protocol spec into concrete protocol strings.
|
||||
*/
|
||||
function expandProtocols(protocol?: 'tcp' | 'udp' | 'both'): string[] {
|
||||
switch (protocol ?? 'tcp') {
|
||||
case 'tcp': return ['tcp'];
|
||||
case 'udp': return ['udp'];
|
||||
case 'both': return ['tcp', 'udp'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a rate limit rule.
|
||||
* Packets exceeding the rate are subjected to the specified action (default: drop).
|
||||
*/
|
||||
export function buildRateLimitRule(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
rule: INftRateLimitRule,
|
||||
): string[] {
|
||||
const protocols = expandProtocols(rule.protocol);
|
||||
const chain = rule.chain ?? 'input';
|
||||
const action = rule.action ?? 'drop';
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const proto of protocols) {
|
||||
const portMatch = rule.port != null ? ` ${proto} dport ${rule.port}` : '';
|
||||
const burstClause = rule.burst != null ? ` burst ${rule.burst} packets` : '';
|
||||
|
||||
if (rule.perSourceIP) {
|
||||
// Per-IP rate limiting using nft meters
|
||||
const meterName = `meter_${proto}_${rule.port ?? 'all'}`;
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} ${chain}${portMatch} meter ${meterName} { ip saddr limit rate over ${rule.rate}${burstClause} } ${action}`
|
||||
);
|
||||
} else {
|
||||
// Global rate limiting
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} ${chain}${portMatch} limit rate over ${rule.rate}${burstClause} ${action}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-IP rate limit rule using nft meters.
|
||||
* Convenience wrapper around buildRateLimitRule with perSourceIP=true.
|
||||
*/
|
||||
export function buildPerIpRateLimitRule(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
rule: Omit<INftRateLimitRule, 'perSourceIP'>,
|
||||
): string[] {
|
||||
return buildRateLimitRule(tableName, family, { ...rule, perSourceIP: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new-connection rate limit rule.
|
||||
* Limits the rate of new connections (ct state new) on the given port.
|
||||
*/
|
||||
export function buildConnectionRateRule(
|
||||
tableName: string,
|
||||
family: TNftFamily,
|
||||
rule: INftConnectionRateRule,
|
||||
): string[] {
|
||||
const protocols = expandProtocols(rule.protocol);
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const proto of protocols) {
|
||||
const portMatch = rule.port != null ? ` ${proto} dport ${rule.port}` : '';
|
||||
|
||||
if (rule.perSourceIP) {
|
||||
const meterName = `connrate_${proto}_${rule.port ?? 'all'}`;
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} input ct state new${portMatch} meter ${meterName} { ip saddr limit rate over ${rule.rate} } drop`
|
||||
);
|
||||
} else {
|
||||
commands.push(
|
||||
`nft add rule ${family} ${tableName} input ct state new${portMatch} limit rate over ${rule.rate} drop`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
30
ts/nft.rulebuilder.table.ts
Normal file
30
ts/nft.rulebuilder.table.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { TNftFamily } from './nft.types.js';
|
||||
|
||||
/**
|
||||
* Build commands to create the nftables table and NAT chains (prerouting + postrouting).
|
||||
*/
|
||||
export function buildTableSetup(tableName: string, family: TNftFamily = 'ip'): string[] {
|
||||
return [
|
||||
`nft add table ${family} ${tableName}`,
|
||||
`nft add chain ${family} ${tableName} prerouting { type nat hook prerouting priority 0 \\; }`,
|
||||
`nft add chain ${family} ${tableName} postrouting { type nat hook postrouting priority 100 \\; }`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build commands to create filter chains (input, forward, output).
|
||||
*/
|
||||
export function buildFilterChains(tableName: string, family: TNftFamily = 'ip'): string[] {
|
||||
return [
|
||||
`nft add chain ${family} ${tableName} input { type filter hook input priority 0 \\; policy accept \\; }`,
|
||||
`nft add chain ${family} ${tableName} forward { type filter hook forward priority 0 \\; policy accept \\; }`,
|
||||
`nft add chain ${family} ${tableName} output { type filter hook output priority 0 \\; policy accept \\; }`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command to delete the entire nftables table.
|
||||
*/
|
||||
export function buildTableCleanup(tableName: string, family: TNftFamily = 'ip'): string[] {
|
||||
return [`nft delete table ${family} ${tableName}`];
|
||||
}
|
||||
102
ts/nft.types.ts
Normal file
102
ts/nft.types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// ─── Protocol & Family ────────────────────────────────────────────
|
||||
export type TNftProtocol = 'tcp' | 'udp' | 'both';
|
||||
export type TNftFamily = 'ip' | 'ip6' | 'inet';
|
||||
export type TNftChainHook = 'prerouting' | 'postrouting' | 'input' | 'output' | 'forward';
|
||||
export type TNftChainType = 'nat' | 'filter';
|
||||
export type TNftPolicy = 'accept' | 'drop';
|
||||
export type TFirewallAction = 'accept' | 'drop' | 'reject';
|
||||
export type TCtState = 'new' | 'established' | 'related' | 'invalid';
|
||||
|
||||
// ─── NAT ──────────────────────────────────────────────────────────
|
||||
export interface INftDnatRule {
|
||||
sourcePort: number;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
protocol?: TNftProtocol;
|
||||
preserveSourceIP?: boolean;
|
||||
}
|
||||
|
||||
export interface INftSnatRule {
|
||||
sourceAddress: string;
|
||||
targetPort: number;
|
||||
protocol?: TNftProtocol;
|
||||
}
|
||||
|
||||
export interface INftMasqueradeRule {
|
||||
targetPort: number;
|
||||
protocol?: TNftProtocol;
|
||||
}
|
||||
|
||||
// ─── Rate Limiting ────────────────────────────────────────────────
|
||||
export interface INftRateLimitRule {
|
||||
/** Port to rate-limit on. If omitted, applies to all ports. */
|
||||
port?: number;
|
||||
protocol?: TNftProtocol;
|
||||
/** Rate expression, e.g. "100/second", "10 mbytes/second" */
|
||||
rate: string;
|
||||
/** Burst allowance in packets or bytes */
|
||||
burst?: number;
|
||||
/** If true, track rate per source IP using nft meters */
|
||||
perSourceIP?: boolean;
|
||||
/** Action for packets exceeding rate. Default: 'drop' */
|
||||
action?: TFirewallAction;
|
||||
/** Chain to apply the rule to. Default: 'input' */
|
||||
chain?: 'input' | 'forward' | 'prerouting';
|
||||
}
|
||||
|
||||
export interface INftConnectionRateRule {
|
||||
/** Port to limit new connections on */
|
||||
port?: number;
|
||||
protocol?: TNftProtocol;
|
||||
/** New connection rate, e.g. "10/second" */
|
||||
rate: string;
|
||||
/** If true, track per source IP */
|
||||
perSourceIP?: boolean;
|
||||
}
|
||||
|
||||
// ─── Firewall ─────────────────────────────────────────────────────
|
||||
export interface INftFirewallRule {
|
||||
direction: 'input' | 'output' | 'forward';
|
||||
action: TFirewallAction;
|
||||
sourceIP?: string;
|
||||
destIP?: string;
|
||||
sourcePort?: number;
|
||||
destPort?: number;
|
||||
protocol?: TNftProtocol;
|
||||
ctStates?: TCtState[];
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface INftIPSetConfig {
|
||||
name: string;
|
||||
type: 'ipv4_addr' | 'ipv6_addr' | 'inet_service';
|
||||
elements?: string[];
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ─── Rule Group (tracking unit) ───────────────────────────────────
|
||||
export interface INftRuleGroup {
|
||||
id: string;
|
||||
commands: string[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ─── Manager Options ──────────────────────────────────────────────
|
||||
export interface ISmartNftablesOptions {
|
||||
/** nftables table name. Default: 'smartnftables' */
|
||||
tableName?: string;
|
||||
/** Address family. Default: 'ip' */
|
||||
family?: TNftFamily;
|
||||
/** If true, generate commands but never execute them */
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
// ─── Status / Reporting ───────────────────────────────────────────
|
||||
export interface INftStatus {
|
||||
initialized: boolean;
|
||||
tableName: string;
|
||||
family: TNftFamily;
|
||||
isRoot: boolean;
|
||||
activeGroups: number;
|
||||
groups: Record<string, { ruleCount: number; createdAt: number }>;
|
||||
}
|
||||
16
ts/plugins.ts
Normal file
16
ts/plugins.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// node native scope
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as util from 'node:util';
|
||||
|
||||
export {
|
||||
childProcess,
|
||||
util,
|
||||
};
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
export {
|
||||
smartlog,
|
||||
smartpromise,
|
||||
};
|
||||
Reference in New Issue
Block a user