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'; const execAsync = promisify(exec); /** * Custom error classes for better error handling */ export class NftBaseError extends Error { constructor(message: string) { super(message); this.name = 'NftBaseError'; } } export class NftValidationError extends NftBaseError { constructor(message: string) { super(message); this.name = 'NftValidationError'; } } export class NftExecutionError extends NftBaseError { constructor(message: string) { super(message); this.name = 'NftExecutionError'; } } export class NftResourceError extends NftBaseError { constructor(message: string) { super(message); this.name = 'NftResourceError'; } } /** * Represents a port range for forwarding */ export interface IPortRange { from: number; to: number; } /** * Settings for NfTablesProxy. */ export interface INfTableProxySettings { // Basic settings fromPort: number | IPortRange | Array; // Support single port, port range, or multiple ports/ranges toPort: number | IPortRange | Array; toHost?: string; // Target host for proxying; defaults to 'localhost' // Advanced settings preserveSourceIP?: boolean; // If true, the original source IP is preserved deleteOnExit?: boolean; // If true, clean up rules before process exit protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward, defaults to 'tcp' enableLogging?: boolean; // Enable detailed logging ipv6Support?: boolean; // Enable IPv6 support logFormat?: 'plain' | 'json'; // Format for logs // Source filtering allowedSourceIPs?: string[]; // If provided, only these IPs are allowed bannedSourceIPs?: string[]; // If provided, these IPs are blocked useIPSets?: boolean; // Use nftables sets for efficient IP management // Rule management forceCleanSlate?: boolean; // Clear all NfTablesProxy rules before starting tableName?: string; // Custom table name (defaults to 'portproxy') // Connection management maxRetries?: number; // Maximum number of retries for failed commands retryDelayMs?: number; // Delay between retries in milliseconds useAdvancedNAT?: boolean; // Use connection tracking for stateful NAT // Quality of Service qos?: { enabled: boolean; maxRate?: string; // e.g. "10mbps" priority?: number; // 1 (highest) to 10 (lowest) markConnections?: boolean; // Mark connections for easier management }; // Integration with PortProxy/NetworkProxy netProxyIntegration?: { enabled: boolean; redirectLocalhost?: boolean; // Redirect localhost traffic to NetworkProxy sslTerminationPort?: number; // Port where NetworkProxy handles SSL termination }; } /** * Represents a rule added to nftables */ interface NfTablesRule { handle?: number; // Rule handle for deletion tableFamily: string; // 'ip' or 'ip6' tableName: string; // Table name chainName: string; // Chain name ruleContents: string; // Rule definition added: boolean; // Whether the rule was successfully added verified?: boolean; // Whether the rule has been verified as applied } /** * Interface for status reporting */ export interface INfTablesStatus { active: boolean; ruleCount: { total: number; added: number; verified: number; }; tablesConfigured: { family: string; tableName: string }[]; metrics: { forwardedConnections?: number; activeConnections?: number; bytesForwarded?: { sent: number; received: number; }; }; qosEnabled?: boolean; ipSetsConfigured?: { name: string; elementCount: number; type: string; }[]; } /** * 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 { public settings: INfTableProxySettings; private rules: NfTablesRule[] = []; private ipSets: Map = new Map(); // Store IP sets for tracking private ruleTag: string; private tableName: string; private tempFilePath: string; private static NFT_CMD = 'nft'; constructor(settings: INfTableProxySettings) { // 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 */ private validateSettings(settings: INfTableProxySettings): void { // Validate port numbers const validatePorts = (port: number | IPortRange | Array) => { 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.allowedSourceIPs); validateIPs(settings.bannedSourceIPs); // 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 | IPortRange | Array): IPortRange[] { const result: IPortRange[] = []; 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 { 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 new Promise(resolve => setTimeout(resolve, retryDelayMs)); } } } throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`); } /** * Execute system command synchronously with multiple attempts */ private executeWithRetrySync(command: string, maxRetries = 3, retryDelayMs = 1000): string { let lastError: Error | undefined; 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) { // A naive sleep in sync context const waitUntil = Date.now() + retryDelayMs; while (Date.now() < waitUntil) { // busy wait - not great, but this is a fallback method } } } } throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`); } /** * Checks if nftables is available and the required modules are loaded */ private async checkNftablesAvailability(): Promise { 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 */ private async setupTablesAndChains(isIpv6: boolean = false): Promise { 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 */ private async createIPSet( family: string, setName: string, ips: string[], setType: 'ipv4_addr' | 'ipv6_addr' = 'ipv4_addr' ): Promise { 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 */ private async addSourceIPFilters(isIpv6: boolean = false): Promise { if (!this.settings.allowedSourceIPs && !this.settings.bannedSourceIPs) { 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.bannedSourceIPs && this.settings.bannedSourceIPs.length > 0) { const setName = 'banned_ips'; await this.createIPSet(family, setName, this.settings.bannedSourceIPs, setType as any); // 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.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) { const setName = 'allowed_ips'; await this.createIPSet(family, setName, this.settings.allowedSourceIPs, setType as any); // 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.bannedSourceIPs && this.settings.bannedSourceIPs.length > 0) { for (const ip of this.settings.bannedSourceIPs) { // 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.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) { // Add rules to allow specific IPs for (const ip of this.settings.allowedSourceIPs) { // 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) { // Write the ruleset to a temporary file fs.writeFileSync(this.tempFilePath, rulesetContent); // Apply the ruleset await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs ); 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); } } // Remove the temporary file fs.unlinkSync(this.tempFilePath); } 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 */ private getAllPorts(portSpec: number | IPortRange | Array): string { const portRanges = this.normalizePortSpec(portSpec); const ports: string[] = []; 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 */ private async setupAdvancedNAT(isIpv6: boolean = false): Promise { 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) { fs.writeFileSync(this.tempFilePath, rulesetContent); await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs ); 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); } } // Remove the temporary file fs.unlinkSync(this.tempFilePath); } } return true; } catch (err) { this.log('error', `Failed to set up advanced NAT: ${err.message}`); return false; } } /** * Adds port forwarding rules */ private async addPortForwardingRules(isIpv6: boolean = false): Promise { // 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 */ private async addPortMappings( family: string, preroutingChain: string, postroutingChain: string, fromPortRanges: IPortRange[], toPortRange: IPortRange ): Promise { 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) { // Write to temporary file fs.writeFileSync(this.tempFilePath, rulesetContent); // Apply the ruleset await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs ); 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); } } // Remove temporary file fs.unlinkSync(this.tempFilePath); } 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 */ private async addPortPairMappings( family: string, preroutingChain: string, postroutingChain: string, fromPortRanges: IPortRange[], toPortRanges: IPortRange[] ): Promise { 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) { // Write to temporary file fs.writeFileSync(this.tempFilePath, rulesetContent); // Apply the ruleset await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs ); 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); } } // Remove temporary file fs.unlinkSync(this.tempFilePath); } return true; } catch (err) { this.log('error', `Failed to add port pair mappings: ${err.message}`); return false; } } /** * Setup quality of service rules */ private async addTrafficShaping(isIpv6: boolean = false): Promise { 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) { // Write to temporary file fs.writeFileSync(this.tempFilePath, rulesetContent); // Apply the ruleset await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs ); 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); } } // Remove temporary file fs.unlinkSync(this.tempFilePath); } return true; } catch (err) { this.log('error', `Failed to add traffic shaping: ${err.message}`); return false; } } /** * Setup NetworkProxy integration rules */ private async setupNetworkProxyIntegration(isIpv6: boolean = false): Promise { 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 */ private async verifyRuleApplication(rule: NfTablesRule): Promise { 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 */ private async rollbackRules(): Promise { // 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 */ private async tableExists(family: string, tableName: string): Promise { 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 */ private async getSystemMetrics(): Promise<{ activeConnections?: number; forwardedConnections?: number; bytesForwarded?: { sent: number; received: number }; }> { const metrics: { activeConnections?: number; forwardedConnections?: number; bytesForwarded?: { sent: number; received: number }; } = {}; 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 */ private async getIPSetStatus(): Promise<{ name: string; elementCount: number; type: string; }[]> { const result: { name: string; elementCount: number; type: string; }[] = []; 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, type: setType }); } } catch (err) { // No sets for this family, or table doesn't exist } } return result; } catch (err) { this.log('error', `Failed to get IP set status: ${err.message}`); return result; } } /** * Get detailed status about the current state of the proxy */ public async getStatus(): Promise { const result: INfTablesStatus = { active: this.rules.some(r => r.added), ruleCount: { total: this.rules.length, added: this.rules.filter(r => r.added).length, verified: this.rules.filter(r => r.verified).length }, tablesConfigured: [], metrics: {}, qosEnabled: this.settings.qos?.enabled || false }; try { // Get list of configured tables const stdout = await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} list tables`, this.settings.maxRetries, this.settings.retryDelayMs ); const tableRegex = /table (ip|ip6) (\w+)/g; let match; while ((match = tableRegex.exec(stdout)) !== null) { const [, family, name] = match; if (name === this.tableName) { result.tablesConfigured.push({ family, tableName: name }); } } // Get system metrics result.metrics = await this.getSystemMetrics(); // Get IP set status if using IP sets if (this.settings.useIPSets) { result.ipSetsConfigured = await this.getIPSetStatus(); } return result; } catch (err) { this.log('error', `Failed to get status: ${err.message}`); return result; } } /** * Performs a dry run to see what commands would be executed without actually applying them */ public async dryRun(): Promise { const commands: string[] = []; // Simulate all the necessary setup steps and collect commands // Tables and chains commands.push(`add table ip ${this.tableName}`); commands.push(`add chain ip ${this.tableName} nat_prerouting { type nat hook prerouting priority -100; }`); if (!this.settings.preserveSourceIP) { commands.push(`add chain ip ${this.tableName} nat_postrouting { type nat hook postrouting priority 100; }`); } if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) { commands.push(`add chain ip ${this.tableName} nat_output { type nat hook output priority 0; }`); } if (this.settings.qos?.enabled) { commands.push(`add chain ip ${this.tableName} qos_forward { type filter hook forward priority 0; }`); } // Add IPv6 tables if enabled if (this.settings.ipv6Support) { commands.push(`add table ip6 ${this.tableName}`); commands.push(`add chain ip6 ${this.tableName} nat_prerouting { type nat hook prerouting priority -100; }`); if (!this.settings.preserveSourceIP) { commands.push(`add chain ip6 ${this.tableName} nat_postrouting { type nat hook postrouting priority 100; }`); } if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) { commands.push(`add chain ip6 ${this.tableName} nat_output { type nat hook output priority 0; }`); } if (this.settings.qos?.enabled) { commands.push(`add chain ip6 ${this.tableName} qos_forward { type filter hook forward priority 0; }`); } } // Source IP filters if (this.settings.useIPSets) { if (this.settings.bannedSourceIPs?.length) { commands.push(`add set ip ${this.tableName} banned_ips { type ipv4_addr; }`); commands.push(`add element ip ${this.tableName} banned_ips { ${this.settings.bannedSourceIPs.join(', ')} }`); commands.push(`add rule ip ${this.tableName} nat_prerouting ip saddr @banned_ips drop comment "${this.ruleTag}:BANNED_SET"`); } if (this.settings.allowedSourceIPs?.length) { commands.push(`add set ip ${this.tableName} allowed_ips { type ipv4_addr; }`); commands.push(`add element ip ${this.tableName} allowed_ips { ${this.settings.allowedSourceIPs.join(', ')} }`); commands.push(`add rule ip ${this.tableName} nat_prerouting ip saddr @allowed_ips ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} accept comment "${this.ruleTag}:ALLOWED_SET"`); commands.push(`add rule ip ${this.tableName} nat_prerouting ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} drop comment "${this.ruleTag}:DENY_ALL"`); } } else if (this.settings.bannedSourceIPs?.length || this.settings.allowedSourceIPs?.length) { // Traditional approach without IP sets if (this.settings.bannedSourceIPs?.length) { for (const ip of this.settings.bannedSourceIPs) { commands.push(`add rule ip ${this.tableName} nat_prerouting ip saddr ${ip} drop comment "${this.ruleTag}:BANNED"`); } } if (this.settings.allowedSourceIPs?.length) { for (const ip of this.settings.allowedSourceIPs) { commands.push(`add rule ip ${this.tableName} nat_prerouting ip saddr ${ip} ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} accept comment "${this.ruleTag}:ALLOWED"`); } commands.push(`add rule ip ${this.tableName} nat_prerouting ${this.settings.protocol} dport {${this.getAllPorts(this.settings.fromPort)}} drop comment "${this.ruleTag}:DENY_ALL"`); } } // 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); if (fromPortRanges.length === 1 && toPortRanges.length === 1) { const fromRange = fromPortRanges[0]; const toRange = toPortRanges[0]; if (fromRange.from === fromRange.to && toRange.from === toRange.to) { commands.push(`add rule ip ${this.tableName} nat_prerouting ${this.settings.protocol} dport ${fromRange.from} ct state new dnat to ${this.settings.toHost}:${toRange.from} comment "${this.ruleTag}:DNAT_CT"`); } else if ((fromRange.to - fromRange.from) === (toRange.to - toRange.from)) { commands.push(`add rule ip ${this.tableName} nat_prerouting ${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"`); } commands.push(`add rule ip ${this.tableName} nat_prerouting ct state established,related accept comment "${this.ruleTag}:CT_ESTABLISHED"`); } } else { // Standard NAT rules const fromRanges = this.normalizePortSpec(this.settings.fromPort); const toRanges = this.normalizePortSpec(this.settings.toPort); if (fromRanges.length === 1 && toRanges.length === 1) { const fromRange = fromRanges[0]; const toRange = toRanges[0]; if (fromRange.from === fromRange.to && toRange.from === toRange.to) { commands.push(`add rule ip ${this.tableName} nat_prerouting ${this.settings.protocol} dport ${fromRange.from} dnat to ${this.settings.toHost}:${toRange.from} comment "${this.ruleTag}:DNAT"`); } else { commands.push(`add rule ip ${this.tableName} nat_prerouting ${this.settings.protocol} dport ${fromRange.from}-${fromRange.to} dnat to ${this.settings.toHost}:${toRange.from}-${toRange.to} comment "${this.ruleTag}:DNAT_RANGE"`); } } else if (toRanges.length === 1) { // One-to-many mapping for (const fromRange of fromRanges) { commands.push(`add rule ip ${this.tableName} nat_prerouting ${this.settings.protocol} dport ${fromRange.from}-${fromRange.to} dnat to ${this.settings.toHost}:${toRanges[0].from}-${toRanges[0].to} comment "${this.ruleTag}:DNAT_RANGE"`); } } else { // One-to-one mapping of multiple ranges for (let i = 0; i < fromRanges.length; i++) { commands.push(`add rule ip ${this.tableName} nat_prerouting ${this.settings.protocol} dport ${fromRanges[i].from}-${fromRanges[i].to} dnat to ${this.settings.toHost}:${toRanges[i].from}-${toRanges[i].to} comment "${this.ruleTag}:DNAT_RANGE"`); } } } // Masquerade rules if not preserving source IP if (!this.settings.preserveSourceIP) { commands.push(`add rule ip ${this.tableName} nat_postrouting ${this.settings.protocol} daddr ${this.settings.toHost} dport {${this.getAllPorts(this.settings.toPort)}} masquerade comment "${this.ruleTag}:MASQ"`); } // NetworkProxy integration if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost && this.settings.netProxyIntegration.sslTerminationPort) { commands.push(`add rule ip ${this.tableName} nat_output ${this.settings.protocol} daddr 127.0.0.1 redirect to :${this.settings.netProxyIntegration.sslTerminationPort} comment "${this.ruleTag}:NETPROXY_REDIRECT"`); } // QoS rules if (this.settings.qos?.enabled) { if (this.settings.qos.maxRate) { commands.push(`add rule ip ${this.tableName} qos_forward 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"`); } 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)) { 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"`); } } } return commands; } /** * Starts the proxy by setting up all nftables rules */ public async start(): Promise { // Check if nftables is available const nftablesAvailable = await this.checkNftablesAvailability(); if (!nftablesAvailable) { throw new NftResourceError('nftables is not available or not properly configured'); } // Optionally clean slate first if (this.settings.forceCleanSlate) { await NfTablesProxy.cleanSlate(); } // Set up tables and chains for IPv4 const setupSuccess = await this.setupTablesAndChains(); if (!setupSuccess) { throw new NftExecutionError('Failed to set up nftables tables and chains'); } // Set up IPv6 tables and chains if enabled if (this.settings.ipv6Support) { const setupIPv6Success = await this.setupTablesAndChains(true); if (!setupIPv6Success) { this.log('warn', 'Failed to set up IPv6 tables and chains, continuing with IPv4 only'); } } // Add source IP filters await this.addSourceIPFilters(); if (this.settings.ipv6Support) { await this.addSourceIPFilters(true); } // Set up advanced NAT with connection tracking if enabled if (this.settings.useAdvancedNAT) { const advancedNatSuccess = await this.setupAdvancedNAT(); if (!advancedNatSuccess) { this.log('warn', 'Failed to set up advanced NAT, falling back to standard NAT'); this.settings.useAdvancedNAT = false; } else if (this.settings.ipv6Support) { await this.setupAdvancedNAT(true); } } // Add port forwarding rules (skip if using advanced NAT) if (!this.settings.useAdvancedNAT) { const forwardingSuccess = await this.addPortForwardingRules(); if (!forwardingSuccess) { throw new NftExecutionError('Failed to add port forwarding rules'); } // Add IPv6 port forwarding rules if enabled if (this.settings.ipv6Support) { const forwardingIPv6Success = await this.addPortForwardingRules(true); if (!forwardingIPv6Success) { this.log('warn', 'Failed to add IPv6 port forwarding rules'); } } } // Set up QoS if enabled if (this.settings.qos?.enabled) { const qosSuccess = await this.addTrafficShaping(); if (!qosSuccess) { this.log('warn', 'Failed to set up QoS rules, continuing without traffic shaping'); } else if (this.settings.ipv6Support) { await this.addTrafficShaping(true); } } // Set up NetworkProxy integration if enabled if (this.settings.netProxyIntegration?.enabled) { const netProxySetupSuccess = await this.setupNetworkProxyIntegration(); if (!netProxySetupSuccess) { this.log('warn', 'Failed to set up NetworkProxy integration'); } if (this.settings.ipv6Support) { await this.setupNetworkProxyIntegration(true); } } // Final check - ensure we have at least one rule added if (this.rules.filter(r => r.added).length === 0) { throw new NftExecutionError('No rules were added'); } this.log('info', 'NfTablesProxy started successfully'); } /** * Stops the proxy by removing all added rules */ public async stop(): Promise { try { let rulesetContent = ''; // Process rules in reverse order (LIFO) for (let i = this.rules.length - 1; i >= 0; i--) { const rule = this.rules[i]; if (rule.added) { // Create delete rules by replacing 'add' with 'delete' const deleteRule = rule.ruleContents.replace('add rule', 'delete rule'); rulesetContent += `${deleteRule}\n`; } } // Apply the ruleset if we have any rules to delete if (rulesetContent) { // Write to temporary file fs.writeFileSync(this.tempFilePath, rulesetContent); // Apply the ruleset await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs ); this.log('info', 'Removed all added rules'); // Mark all rules as removed this.rules.forEach(rule => { rule.added = false; rule.verified = false; }); // Remove temporary file fs.unlinkSync(this.tempFilePath); } // Clean up IP sets if we created any if (this.settings.useIPSets && this.ipSets.size > 0) { for (const [key, _] of this.ipSets) { const [family, setName] = key.split(':'); try { await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`, this.settings.maxRetries, this.settings.retryDelayMs ); this.log('info', `Removed IP set ${setName} from ${family} ${this.tableName}`); } catch (err) { this.log('warn', `Failed to remove IP set ${setName}: ${err.message}`); } } this.ipSets.clear(); } // Optionally clean up tables if they're empty await this.cleanupEmptyTables(); this.log('info', 'NfTablesProxy stopped successfully'); } catch (err) { this.log('error', `Error stopping NfTablesProxy: ${err.message}`); throw err; } } /** * Synchronous version of stop, for use in exit handlers */ public stopSync(): void { try { let rulesetContent = ''; // Process rules in reverse order (LIFO) for (let i = this.rules.length - 1; i >= 0; i--) { const rule = this.rules[i]; if (rule.added) { // Create delete rules by replacing 'add' with 'delete' const deleteRule = rule.ruleContents.replace('add rule', 'delete rule'); rulesetContent += `${deleteRule}\n`; } } // Apply the ruleset if we have any rules to delete if (rulesetContent) { // Write to temporary file fs.writeFileSync(this.tempFilePath, rulesetContent); // Apply the ruleset this.executeWithRetrySync( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs ); this.log('info', 'Removed all added rules'); // Mark all rules as removed this.rules.forEach(rule => { rule.added = false; rule.verified = false; }); // Remove temporary file fs.unlinkSync(this.tempFilePath); } // Clean up IP sets if we created any if (this.settings.useIPSets && this.ipSets.size > 0) { for (const [key, _] of this.ipSets) { const [family, setName] = key.split(':'); try { this.executeWithRetrySync( `${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`, this.settings.maxRetries, this.settings.retryDelayMs ); } catch (err) { // Non-critical error, continue } } } // Optionally clean up tables if they're empty (sync version) this.cleanupEmptyTablesSync(); this.log('info', 'NfTablesProxy stopped successfully'); } catch (err) { this.log('error', `Error stopping NfTablesProxy: ${err.message}`); } } /** * Cleans up empty tables */ private async cleanupEmptyTables(): Promise { // Check if tables are empty, and if so, delete them for (const family of ['ip', 'ip6']) { // Skip IPv6 if not enabled if (family === 'ip6' && !this.settings.ipv6Support) { continue; } try { // Check if table exists const tableExists = await this.tableExists(family, this.tableName); if (!tableExists) { continue; } // Check if the table has any rules const stdout = await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs ); const hasRules = stdout.includes('rule'); if (!hasRules) { // Table is empty, delete it await this.executeWithRetry( `${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs ); this.log('info', `Deleted empty table ${family} ${this.tableName}`); } } catch (err) { this.log('error', `Error cleaning up tables: ${err.message}`); } } } /** * Synchronous version of cleanupEmptyTables */ private cleanupEmptyTablesSync(): void { // Check if tables are empty, and if so, delete them for (const family of ['ip', 'ip6']) { // Skip IPv6 if not enabled if (family === 'ip6' && !this.settings.ipv6Support) { continue; } try { // Check if table exists const tableExistsOutput = this.executeWithRetrySync( `${NfTablesProxy.NFT_CMD} list tables ${family}`, this.settings.maxRetries, this.settings.retryDelayMs ); const tableExists = tableExistsOutput.includes(`table ${family} ${this.tableName}`); if (!tableExists) { continue; } // Check if the table has any rules const stdout = this.executeWithRetrySync( `${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs ); const hasRules = stdout.includes('rule'); if (!hasRules) { // Table is empty, delete it this.executeWithRetrySync( `${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs ); this.log('info', `Deleted empty table ${family} ${this.tableName}`); } } catch (err) { this.log('error', `Error cleaning up tables: ${err.message}`); } } } /** * Removes all nftables rules created by this module */ public static async cleanSlate(): Promise { try { // Check for rules with our comment pattern const stdout = await execAsync(`${NfTablesProxy.NFT_CMD} list ruleset`); // Extract our tables const tableMatches = stdout.stdout.match(/table (ip|ip6) (\w+) {[^}]*NfTablesProxy:[^}]*}/g); if (tableMatches) { for (const tableMatch of tableMatches) { // Extract table family and name const familyMatch = tableMatch.match(/table (ip|ip6) (\w+)/); if (familyMatch) { const family = familyMatch[1]; const tableName = familyMatch[2]; // Delete the table await execAsync(`${NfTablesProxy.NFT_CMD} delete table ${family} ${tableName}`); console.log(`Deleted table ${family} ${tableName} containing NfTablesProxy rules`); } } } else { console.log('No NfTablesProxy rules found to clean up'); } } catch (err) { console.error(`Error in cleanSlate: ${err}`); } } /** * Synchronous version of cleanSlate */ public static cleanSlateSync(): void { try { // Check for rules with our comment pattern const stdout = execSync(`${NfTablesProxy.NFT_CMD} list ruleset`).toString(); // Extract our tables const tableMatches = stdout.match(/table (ip|ip6) (\w+) {[^}]*NfTablesProxy:[^}]*}/g); if (tableMatches) { for (const tableMatch of tableMatches) { // Extract table family and name const familyMatch = tableMatch.match(/table (ip|ip6) (\w+)/); if (familyMatch) { const family = familyMatch[1]; const tableName = familyMatch[2]; // Delete the table execSync(`${NfTablesProxy.NFT_CMD} delete table ${family} ${tableName}`); console.log(`Deleted table ${family} ${tableName} containing NfTablesProxy rules`); } } } else { console.log('No NfTablesProxy rules found to clean up'); } } catch (err) { console.error(`Error in cleanSlateSync: ${err}`); } } /** * Improved logging with structured output */ private log(level: 'info' | 'warn' | 'error' | 'debug', message: string, meta?: Record): void { if (!this.settings.enableLogging && (level === 'info' || level === 'debug')) { return; } const timestamp = new Date().toISOString(); const logData = { timestamp, level: level.toUpperCase(), message, ...meta, context: { instance: this.ruleTag, table: this.tableName } }; // Determine if output should be JSON or plain text based on settings const useJson = this.settings.logFormat === 'json'; if (useJson) { const logOutput = JSON.stringify(logData); console.log(logOutput); return; } // Plain text format const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''; switch (level) { case 'info': console.log(`[${timestamp}] [INFO] ${message}${metaStr}`); break; case 'warn': console.warn(`[${timestamp}] [WARN] ${message}${metaStr}`); break; case 'error': console.error(`[${timestamp}] [ERROR] ${message}${metaStr}`); break; case 'debug': console.log(`[${timestamp}] [DEBUG] ${message}${metaStr}`); break; } } }