feat(proxies): introduce nftables command executor and utilities, default certificate provider, expanded route/socket helper modules, and security improvements

This commit is contained in:
2026-01-30 04:06:32 +00:00
parent f25be4c55a
commit 9697ab3078
27 changed files with 2453 additions and 2048 deletions

View File

@@ -3,3 +3,4 @@
*/
export * from './nftables-proxy.js';
export * from './models/index.js';
export * from './utils/index.js';

View File

@@ -3,10 +3,8 @@ import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { delay } from '../../core/utils/async-utils.js';
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
import {
NftBaseError,
NftValidationError,
NftExecutionError,
NftResourceError
@@ -16,6 +14,12 @@ import type {
NfTableProxyOptions,
NfTablesStatus
} from './models/index.js';
import {
NftCommandExecutor,
normalizePortSpec,
validateSettings,
filterIPsByFamily
} from './utils/index.js';
const execAsync = promisify(exec);
@@ -44,11 +48,12 @@ export class NfTablesProxy {
private ruleTag: string;
private tableName: string;
private tempFilePath: string;
private executor: NftCommandExecutor;
private static NFT_CMD = 'nft';
constructor(settings: NfTableProxyOptions) {
// Validate inputs to prevent command injection
this.validateSettings(settings);
validateSettings(settings);
// Set default settings
this.settings = {
@@ -74,6 +79,16 @@ export class NfTablesProxy {
// Create a temp file path for batch operations
this.tempFilePath = path.join(os.tmpdir(), `nft-rules-${Date.now()}.nft`);
// Create the command executor
this.executor = new NftCommandExecutor(
(level, message, data) => this.log(level, message, data),
{
maxRetries: this.settings.maxRetries,
retryDelayMs: this.settings.retryDelayMs,
tempFilePath: this.tempFilePath
}
);
// Register cleanup handlers if deleteOnExit is true
if (this.settings.deleteOnExit) {
// Synchronous cleanup for 'exit' event (only sync code runs here)
@@ -104,183 +119,17 @@ export class NfTablesProxy {
}
}
/**
* Validates settings to prevent command injection and ensure valid values
*/
private validateSettings(settings: NfTableProxyOptions): void {
// Validate port numbers
const validatePorts = (port: number | PortRange | Array<number | PortRange>) => {
if (Array.isArray(port)) {
port.forEach(p => validatePorts(p));
return;
}
if (typeof port === 'number') {
if (port < 1 || port > 65535) {
throw new NftValidationError(`Invalid port number: ${port}`);
}
} else if (typeof port === 'object') {
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`);
}
}
};
validatePorts(settings.fromPort);
validatePorts(settings.toPort);
// Define regex patterns for validation
const ipRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
// Validate IP addresses
const validateIPs = (ips?: string[]) => {
if (!ips) return;
for (const ip of ips) {
if (!ipRegex.test(ip) && !ipv6Regex.test(ip)) {
throw new NftValidationError(`Invalid IP address format: ${ip}`);
}
}
};
validateIPs(settings.ipAllowList);
validateIPs(settings.ipBlockList);
// Validate toHost - only allow hostnames or IPs
if (settings.toHost) {
const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
if (!hostRegex.test(settings.toHost) && !ipRegex.test(settings.toHost) && !ipv6Regex.test(settings.toHost)) {
throw new NftValidationError(`Invalid host format: ${settings.toHost}`);
}
}
// Validate table name to prevent command injection
if (settings.tableName) {
const tableNameRegex = /^[a-zA-Z0-9_]+$/;
if (!tableNameRegex.test(settings.tableName)) {
throw new NftValidationError(`Invalid table name: ${settings.tableName}. Only alphanumeric characters and underscores are allowed.`);
}
}
// Validate QoS settings if enabled
if (settings.qos?.enabled) {
if (settings.qos.maxRate) {
const rateRegex = /^[0-9]+[kKmMgG]?bps$/;
if (!rateRegex.test(settings.qos.maxRate)) {
throw new NftValidationError(`Invalid rate format: ${settings.qos.maxRate}. Use format like "10mbps", "1gbps", etc.`);
}
}
if (settings.qos.priority !== undefined) {
if (settings.qos.priority < 1 || settings.qos.priority > 10 || !Number.isInteger(settings.qos.priority)) {
throw new NftValidationError(`Invalid priority: ${settings.qos.priority}. Must be an integer between 1 and 10.`);
}
}
}
}
/**
* Normalizes port specifications into an array of port ranges
*/
private normalizePortSpec(portSpec: number | PortRange | Array<number | PortRange>): PortRange[] {
const result: PortRange[] = [];
if (Array.isArray(portSpec)) {
// If it's an array, process each element
for (const spec of portSpec) {
result.push(...this.normalizePortSpec(spec));
}
} else if (typeof portSpec === 'number') {
// Single port becomes a range with the same start and end
result.push({ from: portSpec, to: portSpec });
} else {
// Already a range
result.push(portSpec);
}
return result;
}
/**
* Execute a command with retry capability
*/
private async executeWithRetry(command: string, maxRetries = 3, retryDelayMs = 1000): Promise<string> {
let lastError: Error | undefined;
for (let i = 0; i < maxRetries; i++) {
try {
const { stdout } = await execAsync(command);
return stdout;
} catch (err) {
lastError = err;
this.log('warn', `Command failed (attempt ${i+1}/${maxRetries}): ${command}`, { error: err.message });
// Wait before retry, unless it's the last attempt
if (i < maxRetries - 1) {
await delay(retryDelayMs);
}
}
}
throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
}
/**
* Execute system command synchronously (single attempt, no retry)
* Used only for exit handlers where the process is terminating anyway.
* For normal operations, use the async executeWithRetry method.
*/
private executeSync(command: string): string {
try {
return execSync(command, { timeout: 5000 }).toString();
} catch (err) {
this.log('warn', `Sync command failed: ${command}`, { error: err.message });
throw err;
}
}
/**
* Execute nftables commands with a temporary file
* This helper handles the common pattern of writing rules to a temp file,
* executing nftables with the file, and cleaning up
*/
private async executeWithTempFile(rulesetContent: string): Promise<void> {
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
try {
await this.executeWithRetry(
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
this.settings.maxRetries,
this.settings.retryDelayMs
);
} finally {
// Always clean up the temp file
await AsyncFileSystem.remove(this.tempFilePath);
}
}
/**
* Checks if nftables is available and the required modules are loaded
*/
private async checkNftablesAvailability(): Promise<boolean> {
try {
await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} --version`, this.settings.maxRetries, this.settings.retryDelayMs);
// Check for conntrack support if we're using advanced NAT
if (this.settings.useAdvancedNAT) {
try {
await this.executeWithRetry('lsmod | grep nf_conntrack', this.settings.maxRetries, this.settings.retryDelayMs);
} catch (err) {
this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work');
}
}
return true;
} catch (err) {
this.log('error', `nftables is not available: ${err.message}`);
return false;
const available = await this.executor.checkAvailability();
if (available && this.settings.useAdvancedNAT) {
await this.executor.checkConntrackModules();
}
return available;
}
/**
@@ -291,7 +140,7 @@ export class NfTablesProxy {
try {
// Check if the table already exists
const stdout = await this.executeWithRetry(
const stdout = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -301,7 +150,7 @@ export class NfTablesProxy {
if (!tableExists) {
// Create the table
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add table ${family} ${this.tableName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -310,7 +159,7 @@ export class NfTablesProxy {
this.log('info', `Created table ${family} ${this.tableName}`);
// Create the nat chain for the prerouting hook
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_prerouting { type nat hook prerouting priority -100 ; }`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -320,7 +169,7 @@ export class NfTablesProxy {
// Create the nat chain for the postrouting hook if not preserving source IP
if (!this.settings.preserveSourceIP) {
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_postrouting { type nat hook postrouting priority 100 ; }`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -331,7 +180,7 @@ export class NfTablesProxy {
// Create the chain for NetworkProxy integration if needed
if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) {
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_output { type nat hook output priority 0 ; }`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -342,7 +191,7 @@ export class NfTablesProxy {
// Create the QoS chain if needed
if (this.settings.qos?.enabled) {
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} qos_forward { type filter hook forward priority 0 ; }`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -372,11 +221,7 @@ export class NfTablesProxy {
): Promise<boolean> {
try {
// Filter IPs based on family
const filteredIPs = ips.filter(ip => {
if (family === 'ip6' && ip.includes(':')) return true;
if (family === 'ip' && ip.includes('.')) return true;
return false;
});
const filteredIPs = filterIPsByFamily(ips, family as 'ip' | 'ip6');
if (filteredIPs.length === 0) {
this.log('info', `No IP addresses of type ${setType} to add to set ${setName}`);
@@ -385,7 +230,7 @@ export class NfTablesProxy {
// Check if set already exists
try {
const sets = await this.executeWithRetry(
const sets = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -395,7 +240,7 @@ export class NfTablesProxy {
this.log('info', `IP set ${setName} already exists, will add elements`);
} else {
// Create the set
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -405,7 +250,7 @@ export class NfTablesProxy {
}
} catch (err) {
// Set might not exist yet, create it
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -420,7 +265,7 @@ export class NfTablesProxy {
const batch = filteredIPs.slice(i, i + batchSize);
const elements = batch.join(', ');
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} add element ${family} ${this.tableName} ${setName} { ${elements} }`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -563,7 +408,7 @@ export class NfTablesProxy {
// Only write and apply if we have rules to add
if (rulesetContent) {
// Apply the ruleset using the helper
await this.executeWithTempFile(rulesetContent);
await this.executor.executeWithTempFile(rulesetContent);
this.log('info', `Added source IP filter rules for ${family}`);
@@ -593,7 +438,7 @@ export class NfTablesProxy {
* Gets a comma-separated list of all ports from a port specification
*/
private getAllPorts(portSpec: number | PortRange | Array<number | PortRange>): string {
const portRanges = this.normalizePortSpec(portSpec);
const portRanges = normalizePortSpec(portSpec);
const ports: string[] = [];
for (const range of portRanges) {
@@ -620,8 +465,8 @@ export class NfTablesProxy {
try {
// Get the port ranges
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
const toPortRanges = normalizePortSpec(this.settings.toPort);
let rulesetContent = '';
@@ -670,7 +515,7 @@ export class NfTablesProxy {
// Apply the rules if we have any
if (rulesetContent) {
await this.executeWithTempFile(rulesetContent);
await this.executor.executeWithTempFile(rulesetContent);
this.log('info', `Added advanced NAT rules for ${family}`);
@@ -708,8 +553,8 @@ export class NfTablesProxy {
try {
// Normalize port specifications
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
const toPortRanges = normalizePortSpec(this.settings.toPort);
// Handle the case where fromPort and toPort counts don't match
if (fromPortRanges.length !== toPortRanges.length) {
@@ -815,7 +660,7 @@ export class NfTablesProxy {
// Apply the ruleset if we have any rules
if (rulesetContent) {
// Apply the ruleset using the helper
await this.executeWithTempFile(rulesetContent);
await this.executor.executeWithTempFile(rulesetContent);
this.log('info', `Added port forwarding rules for ${family}`);
@@ -919,7 +764,7 @@ export class NfTablesProxy {
// Apply the ruleset if we have any rules
if (rulesetContent) {
await this.executeWithTempFile(rulesetContent);
await this.executor.executeWithTempFile(rulesetContent);
this.log('info', `Added port forwarding rules for ${family}`);
@@ -972,7 +817,7 @@ export class NfTablesProxy {
// Add priority marking if specified
if (this.settings.qos.priority !== undefined) {
// Check if the chain exists
const chainsOutput = await this.executeWithRetry(
const chainsOutput = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list chains ${family} ${this.tableName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -988,7 +833,7 @@ export class NfTablesProxy {
}
// Add the rules to mark packets with this priority
for (const range of this.normalizePortSpec(this.settings.toPort)) {
for (const range of normalizePortSpec(this.settings.toPort)) {
const markRule = `add rule ${family} ${this.tableName} ${qosChain} ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`;
rulesetContent += `${markRule}\n`;
@@ -1005,7 +850,7 @@ export class NfTablesProxy {
// Apply the ruleset if we have any rules
if (rulesetContent) {
// Apply the ruleset using the helper
await this.executeWithTempFile(rulesetContent);
await this.executor.executeWithTempFile(rulesetContent);
this.log('info', `Added QoS rules for ${family}`);
@@ -1048,7 +893,7 @@ export class NfTablesProxy {
const rule = `add rule ${family} ${this.tableName} ${outputChain} ${this.settings.protocol} daddr ${localhost} redirect to :${netProxyConfig.sslTerminationPort} comment "${this.ruleTag}:NETPROXY_REDIRECT"`;
// Apply the rule
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} ${rule}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1091,7 +936,7 @@ export class NfTablesProxy {
const commentTag = commentMatch[1];
// List the chain to check if our rule is there
const stdout = await this.executeWithRetry(
const stdout = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list chain ${tableFamily} ${tableName} ${chainName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1127,7 +972,7 @@ export class NfTablesProxy {
try {
// For nftables, create a delete rule by replacing 'add' with 'delete'
const deleteRule = rule.ruleContents.replace('add rule', 'delete rule');
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} ${deleteRule}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1149,7 +994,7 @@ export class NfTablesProxy {
*/
private async tableExists(family: string, tableName: string): Promise<boolean> {
try {
const stdout = await this.executeWithRetry(
const stdout = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1178,7 +1023,7 @@ export class NfTablesProxy {
try {
// Try to get connection metrics if conntrack is available
try {
const stdout = await this.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs);
const stdout = await this.executor.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs);
metrics.activeConnections = parseInt(stdout.trim(), 10);
} catch (err) {
// conntrack not available, skip this metric
@@ -1187,7 +1032,7 @@ export class NfTablesProxy {
// Try to get forwarded connections count from nftables counters
try {
// Look for counters in our rules
const stdout = await this.executeWithRetry(
const stdout = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list table ip ${this.tableName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1238,7 +1083,7 @@ export class NfTablesProxy {
try {
for (const family of ['ip', 'ip6']) {
try {
const stdout = await this.executeWithRetry(
const stdout = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1290,7 +1135,7 @@ export class NfTablesProxy {
try {
// Get list of configured tables
const stdout = await this.executeWithRetry(
const stdout = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list tables`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1396,8 +1241,8 @@ export class NfTablesProxy {
// Port forwarding rules
if (this.settings.useAdvancedNAT) {
// Advanced NAT with connection tracking
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
const toPortRanges = normalizePortSpec(this.settings.toPort);
if (fromPortRanges.length === 1 && toPortRanges.length === 1) {
const fromRange = fromPortRanges[0];
@@ -1413,8 +1258,8 @@ export class NfTablesProxy {
}
} else {
// Standard NAT rules
const fromRanges = this.normalizePortSpec(this.settings.fromPort);
const toRanges = this.normalizePortSpec(this.settings.toPort);
const fromRanges = normalizePortSpec(this.settings.fromPort);
const toRanges = normalizePortSpec(this.settings.toPort);
if (fromRanges.length === 1 && toRanges.length === 1) {
const fromRange = fromRanges[0];
@@ -1460,7 +1305,7 @@ export class NfTablesProxy {
if (this.settings.qos.priority !== undefined) {
commands.push(`add chain ip ${this.tableName} prio${this.settings.qos.priority} { type filter hook forward priority ${this.settings.qos.priority * 10}; }`);
for (const range of this.normalizePortSpec(this.settings.toPort)) {
for (const range of normalizePortSpec(this.settings.toPort)) {
commands.push(`add rule ip ${this.tableName} qos_forward ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`);
}
}
@@ -1586,7 +1431,7 @@ export class NfTablesProxy {
try {
// Apply the ruleset
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1611,7 +1456,7 @@ export class NfTablesProxy {
const [family, setName] = key.split(':');
try {
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1661,7 +1506,7 @@ export class NfTablesProxy {
fs.writeFileSync(this.tempFilePath, rulesetContent);
// Apply the ruleset (single attempt, no retry - process is exiting)
this.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
this.executor.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
this.log('info', 'Removed all added rules');
@@ -1685,7 +1530,7 @@ export class NfTablesProxy {
const [family, setName] = key.split(':');
try {
this.executeSync(
this.executor.executeSync(
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`
);
} catch {
@@ -1722,7 +1567,7 @@ export class NfTablesProxy {
}
// Check if the table has any rules
const stdout = await this.executeWithRetry(
const stdout = await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1732,7 +1577,7 @@ export class NfTablesProxy {
if (!hasRules) {
// Table is empty, delete it
await this.executeWithRetry(
await this.executor.executeWithRetry(
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`,
this.settings.maxRetries,
this.settings.retryDelayMs
@@ -1759,7 +1604,7 @@ export class NfTablesProxy {
try {
// Check if table exists
const tableExistsOutput = this.executeSync(
const tableExistsOutput = this.executor.executeSync(
`${NfTablesProxy.NFT_CMD} list tables ${family}`
);
@@ -1770,7 +1615,7 @@ export class NfTablesProxy {
}
// Check if the table has any rules
const stdout = this.executeSync(
const stdout = this.executor.executeSync(
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`
);
@@ -1778,7 +1623,7 @@ export class NfTablesProxy {
if (!hasRules) {
// Table is empty, delete it
this.executeSync(
this.executor.executeSync(
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`
);

View File

@@ -0,0 +1,38 @@
/**
* NFTables Proxy Utilities
*
* This module exports utility functions and classes for NFTables operations.
*/
// Command execution
export { NftCommandExecutor } from './nft-command-executor.js';
export type { INftLoggerFn, INftExecutorOptions } from './nft-command-executor.js';
// Port specification normalization
export {
normalizePortSpec,
validatePorts,
formatPortRange,
portSpecToNftExpr,
rangesOverlap,
mergeOverlappingRanges,
countPorts,
isPortInSpec
} from './nft-port-spec-normalizer.js';
// Rule validation
export {
isValidIP,
isValidIPv4,
isValidIPv6,
isValidHostname,
isValidTableName,
isValidRate,
validateIPs,
validateHost,
validateTableName,
validateQosSettings,
validateSettings,
isIPForFamily,
filterIPsByFamily
} from './nft-rule-validator.js';

View File

@@ -0,0 +1,162 @@
/**
* NFTables Command Executor
*
* Handles command execution with retry logic, temp file management,
* and error handling for nftables operations.
*/
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import { delay } from '../../../core/utils/async-utils.js';
import { AsyncFileSystem } from '../../../core/utils/fs-utils.js';
import { NftExecutionError } from '../models/index.js';
const execAsync = promisify(exec);
export interface INftLoggerFn {
(level: 'info' | 'warn' | 'error' | 'debug', message: string, data?: Record<string, any>): void;
}
export interface INftExecutorOptions {
maxRetries?: number;
retryDelayMs?: number;
tempFilePath?: string;
}
/**
* NFTables command executor with retry logic and temp file support
*/
export class NftCommandExecutor {
private static readonly NFT_CMD = 'nft';
private maxRetries: number;
private retryDelayMs: number;
private tempFilePath: string;
constructor(
private log: INftLoggerFn,
options: INftExecutorOptions = {}
) {
this.maxRetries = options.maxRetries || 3;
this.retryDelayMs = options.retryDelayMs || 1000;
this.tempFilePath = options.tempFilePath || `/tmp/nft-rules-${Date.now()}.nft`;
}
/**
* Execute a command with retry capability
*/
async executeWithRetry(command: string, maxRetries?: number, retryDelayMs?: number): Promise<string> {
const retries = maxRetries ?? this.maxRetries;
const delayMs = retryDelayMs ?? this.retryDelayMs;
let lastError: Error | undefined;
for (let i = 0; i < retries; i++) {
try {
const { stdout } = await execAsync(command);
return stdout;
} catch (err) {
lastError = err as Error;
this.log('warn', `Command failed (attempt ${i+1}/${retries}): ${command}`, { error: lastError.message });
// Wait before retry, unless it's the last attempt
if (i < retries - 1) {
await delay(delayMs);
}
}
}
throw new NftExecutionError(`Failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`);
}
/**
* Execute system command synchronously (single attempt, no retry)
* Used only for exit handlers where the process is terminating anyway.
*/
executeSync(command: string): string {
try {
return execSync(command, { timeout: 5000 }).toString();
} catch (err) {
this.log('warn', `Sync command failed: ${command}`, { error: (err as Error).message });
throw err;
}
}
/**
* Execute nftables commands with a temporary file
*/
async executeWithTempFile(rulesetContent: string): Promise<void> {
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
try {
await this.executeWithRetry(
`${NftCommandExecutor.NFT_CMD} -f ${this.tempFilePath}`,
this.maxRetries,
this.retryDelayMs
);
} finally {
// Always clean up the temp file
await AsyncFileSystem.remove(this.tempFilePath);
}
}
/**
* Check if nftables is available
*/
async checkAvailability(): Promise<boolean> {
try {
await this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} --version`, this.maxRetries, this.retryDelayMs);
return true;
} catch (err) {
this.log('error', `nftables is not available: ${(err as Error).message}`);
return false;
}
}
/**
* Check if connection tracking modules are loaded
*/
async checkConntrackModules(): Promise<boolean> {
try {
await this.executeWithRetry('lsmod | grep nf_conntrack', this.maxRetries, this.retryDelayMs);
return true;
} catch (err) {
this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work');
return false;
}
}
/**
* Run an nft command directly
*/
async nft(args: string): Promise<string> {
return this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} ${args}`, this.maxRetries, this.retryDelayMs);
}
/**
* Run an nft command synchronously (for cleanup on exit)
*/
nftSync(args: string): string {
return this.executeSync(`${NftCommandExecutor.NFT_CMD} ${args}`);
}
/**
* Get the NFT command path
*/
static get nftCmd(): string {
return NftCommandExecutor.NFT_CMD;
}
/**
* Update the temp file path
*/
setTempFilePath(path: string): void {
this.tempFilePath = path;
}
/**
* Update retry settings
*/
setRetryOptions(maxRetries: number, retryDelayMs: number): void {
this.maxRetries = maxRetries;
this.retryDelayMs = retryDelayMs;
}
}

View File

@@ -0,0 +1,125 @@
/**
* NFTables Port Specification Normalizer
*
* Handles normalization and validation of port specifications
* for nftables rules.
*/
import type { PortRange } from '../models/index.js';
import { NftValidationError } from '../models/index.js';
/**
* Normalizes port specifications into an array of port ranges
*/
export function normalizePortSpec(portSpec: number | PortRange | Array<number | PortRange>): PortRange[] {
const result: PortRange[] = [];
if (Array.isArray(portSpec)) {
// If it's an array, process each element
for (const spec of portSpec) {
result.push(...normalizePortSpec(spec));
}
} else if (typeof portSpec === 'number') {
// Single port becomes a range with the same start and end
result.push({ from: portSpec, to: portSpec });
} else {
// Already a range
result.push(portSpec);
}
return result;
}
/**
* Validates port numbers or ranges
*/
export function validatePorts(port: number | PortRange | Array<number | PortRange>): void {
if (Array.isArray(port)) {
port.forEach(p => validatePorts(p));
return;
}
if (typeof port === 'number') {
if (port < 1 || port > 65535) {
throw new NftValidationError(`Invalid port number: ${port}`);
}
} else if (typeof port === 'object') {
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`);
}
}
}
/**
* Format port range for nftables rule
*/
export function formatPortRange(range: PortRange): string {
if (range.from === range.to) {
return String(range.from);
}
return `${range.from}-${range.to}`;
}
/**
* Convert port spec to nftables expression
*/
export function portSpecToNftExpr(portSpec: number | PortRange | Array<number | PortRange>): string {
const ranges = normalizePortSpec(portSpec);
if (ranges.length === 1) {
return formatPortRange(ranges[0]);
}
// Multiple ports/ranges need to use a set
const ports = ranges.map(formatPortRange);
return `{ ${ports.join(', ')} }`;
}
/**
* Check if two port ranges overlap
*/
export function rangesOverlap(range1: PortRange, range2: PortRange): boolean {
return range1.from <= range2.to && range2.from <= range1.to;
}
/**
* Merge overlapping port ranges
*/
export function mergeOverlappingRanges(ranges: PortRange[]): PortRange[] {
if (ranges.length <= 1) return ranges;
// Sort by start port
const sorted = [...ranges].sort((a, b) => a.from - b.from);
const merged: PortRange[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const lastMerged = merged[merged.length - 1];
if (current.from <= lastMerged.to + 1) {
// Ranges overlap or are adjacent, merge them
lastMerged.to = Math.max(lastMerged.to, current.to);
} else {
// No overlap, add as new range
merged.push(current);
}
}
return merged;
}
/**
* Calculate the total number of ports in a port specification
*/
export function countPorts(portSpec: number | PortRange | Array<number | PortRange>): number {
const ranges = normalizePortSpec(portSpec);
return ranges.reduce((total, range) => total + (range.to - range.from + 1), 0);
}
/**
* Check if a port is within the given specification
*/
export function isPortInSpec(port: number, portSpec: number | PortRange | Array<number | PortRange>): boolean {
const ranges = normalizePortSpec(portSpec);
return ranges.some(range => port >= range.from && port <= range.to);
}

View File

@@ -0,0 +1,156 @@
/**
* NFTables Rule Validator
*
* Handles validation of settings and inputs for nftables operations.
* Prevents command injection and ensures valid values.
*/
import type { PortRange, NfTableProxyOptions } from '../models/index.js';
import { NftValidationError } from '../models/index.js';
import { validatePorts } from './nft-port-spec-normalizer.js';
// IP address validation patterns
const IPV4_REGEX = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
const IPV6_REGEX = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
const HOSTNAME_REGEX = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
const TABLE_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
const RATE_REGEX = /^[0-9]+[kKmMgG]?bps$/;
/**
* Validates an IP address (IPv4 or IPv6)
*/
export function isValidIP(ip: string): boolean {
return IPV4_REGEX.test(ip) || IPV6_REGEX.test(ip);
}
/**
* Validates an IPv4 address
*/
export function isValidIPv4(ip: string): boolean {
return IPV4_REGEX.test(ip);
}
/**
* Validates an IPv6 address
*/
export function isValidIPv6(ip: string): boolean {
return IPV6_REGEX.test(ip);
}
/**
* Validates a hostname
*/
export function isValidHostname(hostname: string): boolean {
return HOSTNAME_REGEX.test(hostname);
}
/**
* Validates a table name for nftables
*/
export function isValidTableName(tableName: string): boolean {
return TABLE_NAME_REGEX.test(tableName);
}
/**
* Validates a rate specification (e.g., "10mbps")
*/
export function isValidRate(rate: string): boolean {
return RATE_REGEX.test(rate);
}
/**
* Validates an array of IP addresses
*/
export function validateIPs(ips?: string[]): void {
if (!ips) return;
for (const ip of ips) {
if (!isValidIP(ip)) {
throw new NftValidationError(`Invalid IP address format: ${ip}`);
}
}
}
/**
* Validates a host (can be hostname or IP)
*/
export function validateHost(host?: string): void {
if (!host) return;
if (!isValidHostname(host) && !isValidIP(host)) {
throw new NftValidationError(`Invalid host format: ${host}`);
}
}
/**
* Validates a table name
*/
export function validateTableName(tableName?: string): void {
if (!tableName) return;
if (!isValidTableName(tableName)) {
throw new NftValidationError(
`Invalid table name: ${tableName}. Only alphanumeric characters and underscores are allowed.`
);
}
}
/**
* Validates QoS settings
*/
export function validateQosSettings(qos?: NfTableProxyOptions['qos']): void {
if (!qos?.enabled) return;
if (qos.maxRate && !isValidRate(qos.maxRate)) {
throw new NftValidationError(
`Invalid rate format: ${qos.maxRate}. Use format like "10mbps", "1gbps", etc.`
);
}
if (qos.priority !== undefined) {
if (qos.priority < 1 || qos.priority > 10 || !Number.isInteger(qos.priority)) {
throw new NftValidationError(
`Invalid priority: ${qos.priority}. Must be an integer between 1 and 10.`
);
}
}
}
/**
* Validates all NfTablesProxy settings
*/
export function validateSettings(settings: NfTableProxyOptions): void {
// Validate port numbers
validatePorts(settings.fromPort);
validatePorts(settings.toPort);
// Validate IP addresses
validateIPs(settings.ipAllowList);
validateIPs(settings.ipBlockList);
// Validate target host
validateHost(settings.toHost);
// Validate table name
validateTableName(settings.tableName);
// Validate QoS settings
validateQosSettings(settings.qos);
}
/**
* Check if an IP matches the given family (ip or ip6)
*/
export function isIPForFamily(ip: string, family: 'ip' | 'ip6'): boolean {
if (family === 'ip6') {
return ip.includes(':');
}
return ip.includes('.');
}
/**
* Filter IPs by family
*/
export function filterIPsByFamily(ips: string[], family: 'ip' | 'ip6'): string[] {
return ips.filter(ip => isIPForFamily(ip, family));
}