UNPKG

@push.rocks/smartproxy

Version:

A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.

989 lines (988 loc) 153 kB
import { exec, execSync } from 'child_process'; 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 } from './models/index.js'; const execAsync = promisify(exec); /** * NfTablesProxy sets up nftables NAT rules to forward TCP traffic. * Enhanced with multi-port support, IPv6, connection tracking, metrics, * and more advanced features. */ export class NfTablesProxy { static { this.NFT_CMD = 'nft'; } constructor(settings) { this.rules = []; this.ipSets = new Map(); // Store IP sets for tracking // Validate inputs to prevent command injection this.validateSettings(settings); // Set default settings this.settings = { ...settings, toHost: settings.toHost || 'localhost', protocol: settings.protocol || 'tcp', enableLogging: settings.enableLogging !== undefined ? settings.enableLogging : false, ipv6Support: settings.ipv6Support !== undefined ? settings.ipv6Support : false, tableName: settings.tableName || 'portproxy', logFormat: settings.logFormat || 'plain', useIPSets: settings.useIPSets !== undefined ? settings.useIPSets : true, maxRetries: settings.maxRetries || 3, retryDelayMs: settings.retryDelayMs || 1000, useAdvancedNAT: settings.useAdvancedNAT !== undefined ? settings.useAdvancedNAT : false, }; // Generate a unique identifier for the rules added by this instance this.ruleTag = `NfTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`; // Set table name this.tableName = this.settings.tableName || 'portproxy'; // Create a temp file path for batch operations this.tempFilePath = path.join(os.tmpdir(), `nft-rules-${Date.now()}.nft`); // Register cleanup handlers if deleteOnExit is true if (this.settings.deleteOnExit) { const cleanup = () => { try { this.stopSync(); } catch (err) { this.log('error', 'Error cleaning nftables rules on exit:', { error: err.message }); } }; process.on('exit', cleanup); process.on('SIGINT', () => { cleanup(); process.exit(); }); process.on('SIGTERM', () => { cleanup(); process.exit(); }); } } /** * Validates settings to prevent command injection and ensure valid values */ validateSettings(settings) { // Validate port numbers const validatePorts = (port) => { 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) => { 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 */ normalizePortSpec(portSpec) { const result = []; 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 */ async executeWithRetry(command, maxRetries = 3, retryDelayMs = 1000) { let lastError; 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 with multiple attempts * @deprecated This method blocks the event loop and should be avoided. Use executeWithRetry instead. * WARNING: This method contains a busy wait loop that will block the entire Node.js event loop! */ executeWithRetrySync(command, maxRetries = 3, retryDelayMs = 1000) { // Log deprecation warning console.warn('[DEPRECATION WARNING] executeWithRetrySync blocks the event loop and should not be used. Consider using the async executeWithRetry method instead.'); let lastError; for (let i = 0; i < maxRetries; i++) { try { return execSync(command).toString(); } 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) { // CRITICAL: This busy wait loop blocks the entire event loop! // This is a temporary fallback for sync contexts only. // TODO: Remove this method entirely and make all callers async const waitUntil = Date.now() + retryDelayMs; while (Date.now() < waitUntil) { // Busy wait - blocks event loop } } } } throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`); } /** * 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 */ async executeWithTempFile(rulesetContent) { 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 */ async checkNftablesAvailability() { 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; } } /** * Creates the necessary tables and chains */ async setupTablesAndChains(isIpv6 = false) { const family = isIpv6 ? 'ip6' : 'ip'; try { // Check if the table already exists const stdout = await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} list tables ${family}`, this.settings.maxRetries, this.settings.retryDelayMs); const tableExists = stdout.includes(`table ${family} ${this.tableName}`); if (!tableExists) { // Create the table await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Created table ${family} ${this.tableName}`); // Create the nat chain for the prerouting hook await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_prerouting { type nat hook prerouting priority -100 ; }`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Created nat_prerouting chain in ${family} ${this.tableName}`); // Create the nat chain for the postrouting hook if not preserving source IP if (!this.settings.preserveSourceIP) { await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_postrouting { type nat hook postrouting priority 100 ; }`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Created nat_postrouting chain in ${family} ${this.tableName}`); } // Create the chain for NetworkProxy integration if needed if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) { await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_output { type nat hook output priority 0 ; }`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Created nat_output chain in ${family} ${this.tableName}`); } // Create the QoS chain if needed if (this.settings.qos?.enabled) { await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} qos_forward { type filter hook forward priority 0 ; }`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Created QoS forward chain in ${family} ${this.tableName}`); } } else { this.log('info', `Table ${family} ${this.tableName} already exists, using existing table`); } return true; } catch (err) { this.log('error', `Failed to set up tables and chains: ${err.message}`); return false; } } /** * Creates IP sets for efficient filtering of large IP lists */ async createIPSet(family, setName, ips, setType = 'ipv4_addr') { 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; }); if (filteredIPs.length === 0) { this.log('info', `No IP addresses of type ${setType} to add to set ${setName}`); return true; } // Check if set already exists try { const sets = await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs); if (sets.includes(`set ${setName} {`)) { this.log('info', `IP set ${setName} already exists, will add elements`); } else { // Create the set await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Created IP set ${setName} for ${family} with type ${setType}`); } } catch (err) { // Set might not exist yet, create it await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Created IP set ${setName} for ${family} with type ${setType}`); } // Add IPs to the set in batches to avoid command line length limitations const batchSize = 100; for (let i = 0; i < filteredIPs.length; i += batchSize) { const batch = filteredIPs.slice(i, i + batchSize); const elements = batch.join(', '); await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} add element ${family} ${this.tableName} ${setName} { ${elements} }`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Added batch of ${batch.length} IPs to set ${setName}`); } // Track the IP set this.ipSets.set(`${family}:${setName}`, filteredIPs); return true; } catch (err) { this.log('error', `Failed to create IP set ${setName}: ${err.message}`); return false; } } /** * Adds source IP filtering rules, potentially using IP sets for efficiency */ async addSourceIPFilters(isIpv6 = false) { if (!this.settings.ipAllowList && !this.settings.ipBlockList) { return true; // Nothing to do } const family = isIpv6 ? 'ip6' : 'ip'; const chain = 'nat_prerouting'; const setType = isIpv6 ? 'ipv6_addr' : 'ipv4_addr'; try { // Start building the ruleset file content let rulesetContent = ''; // Using IP sets for more efficient rule processing with large IP lists if (this.settings.useIPSets) { // Create sets for banned and allowed IPs if needed if (this.settings.ipBlockList && this.settings.ipBlockList.length > 0) { const setName = 'banned_ips'; await this.createIPSet(family, setName, this.settings.ipBlockList, setType); // Add rule to drop traffic from banned IPs const rule = `add rule ${family} ${this.tableName} ${chain} ip${isIpv6 ? '6' : ''} saddr @${setName} drop comment "${this.ruleTag}:BANNED_SET"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: chain, ruleContents: rule, added: false }); } if (this.settings.ipAllowList && this.settings.ipAllowList.length > 0) { const setName = 'allowed_ips'; await this.createIPSet(family, setName, this.settings.ipAllowList, setType); // Add rule to allow traffic from allowed IPs const rule = `add rule ${family} ${this.tableName} ${chain} ip${isIpv6 ? '6' : ''} saddr @${setName} ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} accept comment "${this.ruleTag}:ALLOWED_SET"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: chain, ruleContents: rule, added: false }); // Add default deny rule for unlisted IPs const denyRule = `add rule ${family} ${this.tableName} ${chain} ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} drop comment "${this.ruleTag}:DENY_ALL"`; rulesetContent += `${denyRule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: chain, ruleContents: denyRule, added: false }); } } else { // Traditional approach without IP sets - less efficient for large IP lists // Ban specific IPs first if (this.settings.ipBlockList && this.settings.ipBlockList.length > 0) { for (const ip of this.settings.ipBlockList) { // Skip IPv4 addresses for IPv6 rules and vice versa if (isIpv6 && ip.includes('.')) continue; if (!isIpv6 && ip.includes(':')) continue; const rule = `add rule ${family} ${this.tableName} ${chain} ip${isIpv6 ? '6' : ''} saddr ${ip} drop comment "${this.ruleTag}:BANNED"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: chain, ruleContents: rule, added: false }); } } // Allow specific IPs if (this.settings.ipAllowList && this.settings.ipAllowList.length > 0) { // Add rules to allow specific IPs for (const ip of this.settings.ipAllowList) { // Skip IPv4 addresses for IPv6 rules and vice versa if (isIpv6 && ip.includes('.')) continue; if (!isIpv6 && ip.includes(':')) continue; const rule = `add rule ${family} ${this.tableName} ${chain} ip${isIpv6 ? '6' : ''} saddr ${ip} ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} accept comment "${this.ruleTag}:ALLOWED"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: chain, ruleContents: rule, added: false }); } // Add default deny rule for unlisted IPs const denyRule = `add rule ${family} ${this.tableName} ${chain} ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} drop comment "${this.ruleTag}:DENY_ALL"`; rulesetContent += `${denyRule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: chain, ruleContents: denyRule, added: false }); } } // Only write and apply if we have rules to add if (rulesetContent) { // Apply the ruleset using the helper await this.executeWithTempFile(rulesetContent); this.log('info', `Added source IP filter rules for ${family}`); // Mark rules as added for (const rule of this.rules) { if (rule.tableFamily === family && !rule.added) { rule.added = true; // Verify the rule was applied await this.verifyRuleApplication(rule); } } } return true; } catch (err) { this.log('error', `Failed to add source IP filter rules: ${err.message}`); // Try to clean up any rules that might have been added this.rollbackRules(); return false; } } /** * Gets a comma-separated list of all ports from a port specification */ getAllPorts(portSpec) { const portRanges = this.normalizePortSpec(portSpec); const ports = []; for (const range of portRanges) { if (range.from === range.to) { ports.push(range.from.toString()); } else { ports.push(`${range.from}-${range.to}`); } } return ports.join(', '); } /** * Configures advanced NAT with connection tracking */ async setupAdvancedNAT(isIpv6 = false) { if (!this.settings.useAdvancedNAT) { return true; // Skip if not using advanced NAT } const family = isIpv6 ? 'ip6' : 'ip'; const preroutingChain = 'nat_prerouting'; try { // Get the port ranges const fromPortRanges = this.normalizePortSpec(this.settings.fromPort); const toPortRanges = this.normalizePortSpec(this.settings.toPort); let rulesetContent = ''; // Simple case - one-to-one mapping with connection tracking if (fromPortRanges.length === 1 && toPortRanges.length === 1) { const fromRange = fromPortRanges[0]; const toRange = toPortRanges[0]; // Single port to single port with connection tracking if (fromRange.from === fromRange.to && toRange.from === toRange.to) { const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${fromRange.from} ct state new dnat to ${this.settings.toHost}:${toRange.from} comment "${this.ruleTag}:DNAT_CT"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } // Port range with same size else if ((fromRange.to - fromRange.from) === (toRange.to - toRange.from)) { const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${fromRange.from}-${fromRange.to} ct state new dnat to ${this.settings.toHost}:${toRange.from}-${toRange.to} comment "${this.ruleTag}:DNAT_RANGE_CT"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } // Add related and established connection rule for efficient connection handling const ctRule = `add rule ${family} ${this.tableName} ${preroutingChain} ct state established,related accept comment "${this.ruleTag}:CT_ESTABLISHED"`; rulesetContent += `${ctRule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: ctRule, added: false }); // Apply the rules if we have any if (rulesetContent) { await this.executeWithTempFile(rulesetContent); this.log('info', `Added advanced NAT rules for ${family}`); // Mark rules as added for (const rule of this.rules) { if (rule.tableFamily === family && !rule.added) { rule.added = true; // Verify the rule was applied await this.verifyRuleApplication(rule); } } } } return true; } catch (err) { this.log('error', `Failed to set up advanced NAT: ${err.message}`); return false; } } /** * Adds port forwarding rules */ async addPortForwardingRules(isIpv6 = false) { // Skip if using advanced NAT as that already handles the port forwarding if (this.settings.useAdvancedNAT) { return true; } const family = isIpv6 ? 'ip6' : 'ip'; const preroutingChain = 'nat_prerouting'; const postroutingChain = 'nat_postrouting'; try { // Normalize port specifications const fromPortRanges = this.normalizePortSpec(this.settings.fromPort); const toPortRanges = this.normalizePortSpec(this.settings.toPort); // Handle the case where fromPort and toPort counts don't match if (fromPortRanges.length !== toPortRanges.length) { if (toPortRanges.length === 1) { // If there's only one toPort, use it for all fromPorts const singleToRange = toPortRanges[0]; return await this.addPortMappings(family, preroutingChain, postroutingChain, fromPortRanges, singleToRange); } else { throw new NftValidationError('Mismatched port counts: fromPort and toPort arrays must have equal length or toPort must be a single value'); } } else { // Add port mapping rules for each port pair return await this.addPortPairMappings(family, preroutingChain, postroutingChain, fromPortRanges, toPortRanges); } } catch (err) { this.log('error', `Failed to add port forwarding rules: ${err.message}`); return false; } } /** * Adds port forwarding rules for the case where one toPortRange maps to multiple fromPortRanges */ async addPortMappings(family, preroutingChain, postroutingChain, fromPortRanges, toPortRange) { try { let rulesetContent = ''; // For each from port range, create a mapping to the single to port range for (const fromRange of fromPortRanges) { // Simple case: single port to single port if (fromRange.from === fromRange.to && toPortRange.from === toPortRange.to) { const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${fromRange.from} dnat to ${this.settings.toHost}:${toPortRange.from} comment "${this.ruleTag}:DNAT"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } // Multiple ports in from range, but only one port in to range else if (toPortRange.from === toPortRange.to) { // Map each port in from range to the single to port for (let p = fromRange.from; p <= fromRange.to; p++) { const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${p} dnat to ${this.settings.toHost}:${toPortRange.from} comment "${this.ruleTag}:DNAT"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } } // Port range to port range mapping with modulo distribution else { const toRangeSize = toPortRange.to - toPortRange.from + 1; for (let p = fromRange.from; p <= fromRange.to; p++) { const offset = (p - fromRange.from) % toRangeSize; const targetPort = toPortRange.from + offset; const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${p} dnat to ${this.settings.toHost}:${targetPort} comment "${this.ruleTag}:DNAT"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } } } // Add masquerade rule for source NAT if not preserving source IP if (!this.settings.preserveSourceIP) { const ports = this.getAllPorts(this.settings.toPort); const masqRule = `add rule ${family} ${this.tableName} ${postroutingChain} ${this.settings.protocol} daddr ${this.settings.toHost} dport {${ports}} masquerade comment "${this.ruleTag}:MASQ"`; rulesetContent += `${masqRule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: postroutingChain, ruleContents: masqRule, added: false }); } // Apply the ruleset if we have any rules if (rulesetContent) { // Apply the ruleset using the helper await this.executeWithTempFile(rulesetContent); this.log('info', `Added port forwarding rules for ${family}`); // Mark rules as added for (const rule of this.rules) { if (rule.tableFamily === family && !rule.added) { rule.added = true; // Verify the rule was applied await this.verifyRuleApplication(rule); } } } return true; } catch (err) { this.log('error', `Failed to add port mappings: ${err.message}`); return false; } } /** * Adds port forwarding rules for pairs of fromPortRanges and toPortRanges */ async addPortPairMappings(family, preroutingChain, postroutingChain, fromPortRanges, toPortRanges) { try { let rulesetContent = ''; // Process each fromPort and toPort pair for (let i = 0; i < fromPortRanges.length; i++) { const fromRange = fromPortRanges[i]; const toRange = toPortRanges[i]; // Simple case: single port to single port if (fromRange.from === fromRange.to && toRange.from === toRange.to) { const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${fromRange.from} dnat to ${this.settings.toHost}:${toRange.from} comment "${this.ruleTag}:DNAT"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } // Port range with equal size - can use direct mapping else if ((fromRange.to - fromRange.from) === (toRange.to - toRange.from)) { const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${fromRange.from}-${fromRange.to} dnat to ${this.settings.toHost}:${toRange.from}-${toRange.to} comment "${this.ruleTag}:DNAT_RANGE"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } // Unequal port ranges - need to map individually else { const toRangeSize = toRange.to - toRange.from + 1; for (let p = fromRange.from; p <= fromRange.to; p++) { const offset = (p - fromRange.from) % toRangeSize; const targetPort = toRange.from + offset; const rule = `add rule ${family} ${this.tableName} ${preroutingChain} ${this.settings.protocol} dport ${p} dnat to ${this.settings.toHost}:${targetPort} comment "${this.ruleTag}:DNAT_INDIVIDUAL"`; rulesetContent += `${rule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: preroutingChain, ruleContents: rule, added: false }); } } // Add masquerade rule for this port range if not preserving source IP if (!this.settings.preserveSourceIP) { const masqRule = `add rule ${family} ${this.tableName} ${postroutingChain} ${this.settings.protocol} daddr ${this.settings.toHost} dport ${toRange.from}-${toRange.to} masquerade comment "${this.ruleTag}:MASQ"`; rulesetContent += `${masqRule}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: postroutingChain, ruleContents: masqRule, added: false }); } } // Apply the ruleset if we have any rules if (rulesetContent) { await this.executeWithTempFile(rulesetContent); this.log('info', `Added port forwarding rules for ${family}`); // Mark rules as added for (const rule of this.rules) { if (rule.tableFamily === family && !rule.added) { rule.added = true; // Verify the rule was applied await this.verifyRuleApplication(rule); } } } return true; } catch (err) { this.log('error', `Failed to add port pair mappings: ${err.message}`); return false; } } /** * Setup quality of service rules */ async addTrafficShaping(isIpv6 = false) { if (!this.settings.qos?.enabled) { return true; } const family = isIpv6 ? 'ip6' : 'ip'; const qosChain = 'qos_forward'; try { let rulesetContent = ''; // Add rate limiting rule if specified if (this.settings.qos.maxRate) { const ruleContent = `add rule ${family} ${this.tableName} ${qosChain} ip daddr ${this.settings.toHost} ${this.settings.protocol} dport {${this.getAllPorts(this.settings.toPort)}} limit rate over ${this.settings.qos.maxRate} drop comment "${this.ruleTag}:QOS_RATE"`; rulesetContent += `${ruleContent}\n`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: qosChain, ruleContents: ruleContent, added: false }); } // Add priority marking if specified if (this.settings.qos.priority !== undefined) { // Check if the chain exists const chainsOutput = await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} list chains ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs); // Check if we need to create priority queues const hasPrioChain = chainsOutput.includes(`chain prio${this.settings.qos.priority}`); if (!hasPrioChain) { // Create priority chain const prioChainRule = `add chain ${family} ${this.tableName} prio${this.settings.qos.priority} { type filter hook forward priority ${this.settings.qos.priority * 10}; }`; rulesetContent += `${prioChainRule}\n`; } // Add the rules to mark packets with this priority for (const range of this.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`; this.rules.push({ tableFamily: family, tableName: this.tableName, chainName: qosChain, ruleContents: markRule, added: false }); } } // Apply the ruleset if we have any rules if (rulesetContent) { // Apply the ruleset using the helper await this.executeWithTempFile(rulesetContent); this.log('info', `Added QoS rules for ${family}`); // Mark rules as added for (const rule of this.rules) { if (rule.tableFamily === family && !rule.added) { rule.added = true; // Verify the rule was applied await this.verifyRuleApplication(rule); } } } return true; } catch (err) { this.log('error', `Failed to add traffic shaping: ${err.message}`); return false; } } /** * Setup NetworkProxy integration rules */ async setupNetworkProxyIntegration(isIpv6 = false) { if (!this.settings.netProxyIntegration?.enabled) { return true; } const netProxyConfig = this.settings.netProxyIntegration; const family = isIpv6 ? 'ip6' : 'ip'; const outputChain = 'nat_output'; try { // Only proceed if we're redirecting localhost and have a port if (netProxyConfig.redirectLocalhost && netProxyConfig.sslTerminationPort) { const localhost = isIpv6 ? '::1' : '127.0.0.1'; // Create the redirect rule 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(`${NfTablesProxy.NFT_CMD} ${rule}`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Added NetworkProxy redirection rule for ${family}`); const newRule = { tableFamily: family, tableName: this.tableName, chainName: outputChain, ruleContents: rule, added: true }; this.rules.push(newRule); // Verify the rule was actually applied await this.verifyRuleApplication(newRule); } return true; } catch (err) { this.log('error', `Failed to set up NetworkProxy integration: ${err.message}`); return false; } } /** * Verify that a rule was successfully applied */ async verifyRuleApplication(rule) { try { const { tableFamily, tableName, chainName, ruleContents } = rule; // Extract the distinctive parts of the rule to create a search pattern const commentMatch = ruleContents.match(/comment "([^"]+)"/); if (!commentMatch) return false; const commentTag = commentMatch[1]; // List the chain to check if our rule is there const stdout = await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} list chain ${tableFamily} ${tableName} ${chainName}`, this.settings.maxRetries, this.settings.retryDelayMs); // Check if the comment appears in the output const isApplied = stdout.includes(commentTag); rule.verified = isApplied; if (!isApplied) { this.log('warn', `Rule verification failed: ${commentTag} not found in chain ${chainName}`); } else { this.log('debug', `Rule verified: ${commentTag} found in chain ${chainName}`); } return isApplied; } catch (err) { this.log('error', `Failed to verify rule application: ${err.message}`); return false; } } /** * Rolls back rules in case of error during setup */ async rollbackRules() { // Process rules in reverse order (LIFO) for (let i = this.rules.length - 1; i >= 0; i--) { const rule = this.rules[i]; if (rule.added) { try { // For nftables, create a delete rule by replacing 'add' with 'delete' const deleteRule = rule.ruleContents.replace('add rule', 'delete rule'); await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} ${deleteRule}`, this.settings.maxRetries, this.settings.retryDelayMs); this.log('info', `Rolled back rule: ${deleteRule}`); rule.added = false; rule.verified = false; } catch (err) { this.log('error', `Failed to roll back rule: ${err.message}`); } } } } /** * Checks if nftables table exists */ async tableExists(family, tableName) { try { const stdout = await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} list tables ${family}`, this.settings.maxRetries, this.settings.retryDelayMs); return stdout.includes(`table ${family} ${tableName}`); } catch (err) { return false; } } /** * Get system metrics like connection counts */ async getSystemMetrics() { const metrics = {}; try { // Try to get connection metrics if conntrack is available try { const stdout = await this.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs); metrics.activeConnections = parseInt(stdout.trim(), 10); } catch (err) { // conntrack not available, skip this metric } // Try to get forwarded connections count from nftables counters try { // Look for counters in our rules const stdout = await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} list table ip ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs); // Parse counter information from the output const counterMatches = stdout.matchAll(/counter packets (\d+) bytes (\d+)/g); let totalPackets = 0; let totalBytes = 0; for (const match of counterMatches) { totalPackets += parseInt(match[1], 10); totalBytes += parseInt(match[2], 10); } if (totalPackets > 0) { metrics.forwardedConnections = totalPackets; metrics.bytesForwarded = { sent: totalBytes, received: 0 // We can't easily determine this without additional rules }; } } catch (err) { // Failed to get counter info, skip this metric } return metrics; } catch (err) { this.log('error', `Failed to get system metrics: ${err.message}`); return metrics; } } /** * Get status of IP sets */ async getIPSetStatus() { const result = []; try { for (const family of ['ip', 'ip6']) { try { const stdout = await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs); const setMatches = stdout.matchAll(/set (\w+) {\s*type (\w+)/g); for (const match of setMatches) { const setName = match[1]; const setType = match[2]; // Get element count from tracking map const setKey = `${family}:${setName}`; const elements = this.ipSets.get(setKey) || []; result.push({ name: setName, elementCount: elements.length,