From 9b5b8225bc1a56e50cfea8373550a7cca851d46d Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Tue, 18 Mar 2025 21:55:09 +0000 Subject: [PATCH] BREAKING CHANGE(nftables): Replace IPTablesProxy with NfTablesProxy and update module exports in index.ts --- changelog.md | 7 + ts/00_commitinfo_data.ts | 2 +- ts/classes.iptablesproxy.ts | 901 --------------- ts/classes.nftablesproxy.ts | 2045 +++++++++++++++++++++++++++++++++++ ts/index.ts | 2 +- 5 files changed, 2054 insertions(+), 903 deletions(-) delete mode 100644 ts/classes.iptablesproxy.ts create mode 100644 ts/classes.nftablesproxy.ts diff --git a/changelog.md b/changelog.md index d1db8b3..a386d17 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2025-03-18 - 5.0.0 - BREAKING CHANGE(nftables) +Replace IPTablesProxy with NfTablesProxy and update module exports in index.ts + +- Removed ts/classes.iptablesproxy.ts +- Added ts/classes.nftablesproxy.ts for enhanced nftables integration +- Updated ts/index.ts to export NfTablesProxy instead of IPTablesProxy + ## 2025-03-18 - 4.3.0 - feat(Port80Handler) Add glob pattern support for domain certificate management in Port80Handler. Wildcard domains are now detected and skipped in certificate issuance and retrieval, ensuring that only explicit domains receive ACME certificates and improving route matching. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e682580..e707785 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '4.3.0', + version: '5.0.0', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' } diff --git a/ts/classes.iptablesproxy.ts b/ts/classes.iptablesproxy.ts deleted file mode 100644 index 2061f34..0000000 --- a/ts/classes.iptablesproxy.ts +++ /dev/null @@ -1,901 +0,0 @@ -import { exec, execSync } from 'child_process'; -import { promisify } from 'util'; - -const execAsync = promisify(exec); - -/** - * Represents a port range for forwarding - */ -export interface IPortRange { - from: number; - to: number; -} - -/** - * Settings for IPTablesProxy. - */ -export interface IIpTableProxySettings { - // 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 marked iptables rules before process exit - protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward, defaults to 'tcp' - enableLogging?: boolean; // Enable detailed logging - ipv6Support?: boolean; // Enable IPv6 support (ip6tables) - - // Source filtering - allowedSourceIPs?: string[]; // If provided, only these IPs are allowed - bannedSourceIPs?: string[]; // If provided, these IPs are blocked - - // Rule management - forceCleanSlate?: boolean; // Clear all IPTablesProxy rules before starting - addJumpRule?: boolean; // Add a custom chain for cleaner rule management - checkExistingRules?: boolean; // Check if rules already exist before adding - - // 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 iptables - */ -interface IpTablesRule { - table: string; - chain: string; - command: string; - tag: string; - added: boolean; -} - -/** - * IPTablesProxy sets up iptables NAT rules to forward TCP traffic. - * Enhanced with multi-port support, IPv6, and integration with PortProxy/NetworkProxy. - */ -export class IPTablesProxy { - public settings: IIpTableProxySettings; - private rules: IpTablesRule[] = []; - private ruleTag: string; - private customChain: string | null = null; - - constructor(settings: IIpTableProxySettings) { - // 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, - checkExistingRules: settings.checkExistingRules !== undefined ? settings.checkExistingRules : true, - netProxyIntegration: settings.netProxyIntegration || { enabled: false } - }; - - // Generate a unique identifier for the rules added by this instance - this.ruleTag = `IPTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`; - - if (this.settings.addJumpRule) { - this.customChain = `IPTablesProxy_${Math.random().toString(36).substr(2, 5)}`; - } - - // Register cleanup handlers if deleteOnExit is true - if (this.settings.deleteOnExit) { - const cleanup = () => { - try { - this.stopSync(); - } catch (err) { - console.error('Error cleaning iptables rules on exit:', err); - } - }; - - 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: IIpTableProxySettings): 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 Error(`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 Error(`Invalid port range: ${port.from}-${port.to}`); - } - } - }; - - validatePorts(settings.fromPort); - validatePorts(settings.toPort); - - // Define regex patterns at the method level so they're available throughout - 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 Error(`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 Error(`Invalid host format: ${settings.toHost}`); - } - } - } - - /** - * 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; - } - - /** - * Gets the appropriate iptables command based on settings - */ - private getIptablesCommand(isIpv6: boolean = false): string { - return isIpv6 ? 'ip6tables' : 'iptables'; - } - - /** - * Checks if a rule already exists in iptables - */ - private async ruleExists(table: string, command: string, isIpv6: boolean = false): Promise { - try { - const iptablesCmd = this.getIptablesCommand(isIpv6); - const { stdout } = await execAsync(`${iptablesCmd}-save -t ${table}`); - // Convert the command to the format found in iptables-save output - // (This is a simplification - in reality, you'd need more parsing) - const rulePattern = command.replace(`${iptablesCmd} -t ${table} -A `, '-A '); - return stdout.split('\n').some(line => line.trim() === rulePattern); - } catch (err) { - this.log('error', `Failed to check if rule exists: ${err}`); - return false; - } - } - - /** - * Sets up a custom chain for better rule management - */ - private async setupCustomChain(isIpv6: boolean = false): Promise { - if (!this.customChain) return true; - - const iptablesCmd = this.getIptablesCommand(isIpv6); - const table = 'nat'; - - try { - // Create the chain - await execAsync(`${iptablesCmd} -t ${table} -N ${this.customChain}`); - this.log('info', `Created custom chain: ${this.customChain}`); - - // Add jump rule to PREROUTING chain - const jumpCommand = `${iptablesCmd} -t ${table} -A PREROUTING -j ${this.customChain} -m comment --comment "${this.ruleTag}:JUMP"`; - await execAsync(jumpCommand); - this.log('info', `Added jump rule to ${this.customChain}`); - - // Store the jump rule - this.rules.push({ - table, - chain: 'PREROUTING', - command: jumpCommand, - tag: `${this.ruleTag}:JUMP`, - added: true - }); - - return true; - } catch (err) { - this.log('error', `Failed to set up custom chain: ${err}`); - return false; - } - } - - /** - * Add a source IP filter rule - */ - private async addSourceIPFilter(isIpv6: boolean = false): Promise { - if (!this.settings.allowedSourceIPs && !this.settings.bannedSourceIPs) { - return true; - } - - const iptablesCmd = this.getIptablesCommand(isIpv6); - const table = 'nat'; - const chain = this.customChain || 'PREROUTING'; - - try { - // Add banned IPs first (explicit deny) - if (this.settings.bannedSourceIPs && this.settings.bannedSourceIPs.length > 0) { - for (const ip of this.settings.bannedSourceIPs) { - const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -j DROP -m comment --comment "${this.ruleTag}:BANNED"`; - - // Check if rule already exists - if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${command}`); - continue; - } - - await execAsync(command); - this.log('info', `Added banned IP rule: ${command}`); - - this.rules.push({ - table, - chain, - command, - tag: `${this.ruleTag}:BANNED`, - added: true - }); - } - } - - // Add allowed IPs (explicit allow) - if (this.settings.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) { - // First add a default deny for all - const denyAllCommand = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} -j DROP -m comment --comment "${this.ruleTag}:DENY_ALL"`; - - // Add allow rules for specific IPs - for (const ip of this.settings.allowedSourceIPs) { - const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -p ${this.settings.protocol} -j ACCEPT -m comment --comment "${this.ruleTag}:ALLOWED"`; - - // Check if rule already exists - if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${command}`); - continue; - } - - await execAsync(command); - this.log('info', `Added allowed IP rule: ${command}`); - - this.rules.push({ - table, - chain, - command, - tag: `${this.ruleTag}:ALLOWED`, - added: true - }); - } - - // Now add the default deny after all allows - if (this.settings.checkExistingRules && await this.ruleExists(table, denyAllCommand, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${denyAllCommand}`); - } else { - await execAsync(denyAllCommand); - this.log('info', `Added default deny rule: ${denyAllCommand}`); - - this.rules.push({ - table, - chain, - command: denyAllCommand, - tag: `${this.ruleTag}:DENY_ALL`, - added: true - }); - } - } - - return true; - } catch (err) { - this.log('error', `Failed to add source IP filter rules: ${err}`); - return false; - } - } - - /** - * Adds a port forwarding rule - */ - private async addPortForwardingRule( - fromPortRange: IPortRange, - toPortRange: IPortRange, - isIpv6: boolean = false - ): Promise { - const iptablesCmd = this.getIptablesCommand(isIpv6); - const table = 'nat'; - const chain = this.customChain || 'PREROUTING'; - - try { - // Handle single port case - if (fromPortRange.from === fromPortRange.to && toPortRange.from === toPortRange.to) { - // Single port forward - const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from} ` + - `-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from} ` + - `-m comment --comment "${this.ruleTag}:DNAT"`; - - // Check if rule already exists - if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${command}`); - } else { - await execAsync(command); - this.log('info', `Added port forwarding rule: ${command}`); - - this.rules.push({ - table, - chain, - command, - tag: `${this.ruleTag}:DNAT`, - added: true - }); - } - } else if (fromPortRange.to - fromPortRange.from === toPortRange.to - toPortRange.from) { - // Port range forward with equal ranges - const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from}:${fromPortRange.to} ` + - `-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from}-${toPortRange.to} ` + - `-m comment --comment "${this.ruleTag}:DNAT_RANGE"`; - - // Check if rule already exists - if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${command}`); - } else { - await execAsync(command); - this.log('info', `Added port range forwarding rule: ${command}`); - - this.rules.push({ - table, - chain, - command, - tag: `${this.ruleTag}:DNAT_RANGE`, - added: true - }); - } - } else { - // Unequal port ranges need individual rules - for (let i = 0; i <= fromPortRange.to - fromPortRange.from; i++) { - const fromPort = fromPortRange.from + i; - const toPort = toPortRange.from + i % (toPortRange.to - toPortRange.from + 1); - - const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPort} ` + - `-j DNAT --to-destination ${this.settings.toHost}:${toPort} ` + - `-m comment --comment "${this.ruleTag}:DNAT_INDIVIDUAL"`; - - // Check if rule already exists - if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${command}`); - continue; - } - - await execAsync(command); - this.log('info', `Added individual port forwarding rule: ${command}`); - - this.rules.push({ - table, - chain, - command, - tag: `${this.ruleTag}:DNAT_INDIVIDUAL`, - added: true - }); - } - } - - // If preserveSourceIP is false, add a MASQUERADE rule - if (!this.settings.preserveSourceIP) { - // For port range - const masqCommand = `${iptablesCmd} -t nat -A POSTROUTING -p ${this.settings.protocol} -d ${this.settings.toHost} ` + - `--dport ${toPortRange.from}:${toPortRange.to} -j MASQUERADE ` + - `-m comment --comment "${this.ruleTag}:MASQ"`; - - // Check if rule already exists - if (this.settings.checkExistingRules && await this.ruleExists('nat', masqCommand, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${masqCommand}`); - } else { - await execAsync(masqCommand); - this.log('info', `Added MASQUERADE rule: ${masqCommand}`); - - this.rules.push({ - table: 'nat', - chain: 'POSTROUTING', - command: masqCommand, - tag: `${this.ruleTag}:MASQ`, - added: true - }); - } - } - - return true; - } catch (err) { - this.log('error', `Failed to add port forwarding rule: ${err}`); - - // Try to roll back any rules that were already added - await this.rollbackRules(); - - return false; - } - } - - /** - * Special handling for NetworkProxy integration - */ - private async setupNetworkProxyIntegration(isIpv6: boolean = false): Promise { - if (!this.settings.netProxyIntegration?.enabled) { - return true; - } - - const netProxyConfig = this.settings.netProxyIntegration; - const iptablesCmd = this.getIptablesCommand(isIpv6); - const table = 'nat'; - const chain = this.customChain || 'PREROUTING'; - - try { - // If redirectLocalhost is true, set up special rule to redirect localhost traffic to NetworkProxy - if (netProxyConfig.redirectLocalhost && netProxyConfig.sslTerminationPort) { - const redirectCommand = `${iptablesCmd} -t ${table} -A OUTPUT -p tcp -d 127.0.0.1 -j REDIRECT ` + - `--to-port ${netProxyConfig.sslTerminationPort} ` + - `-m comment --comment "${this.ruleTag}:NETPROXY_REDIRECT"`; - - // Check if rule already exists - if (this.settings.checkExistingRules && await this.ruleExists(table, redirectCommand, isIpv6)) { - this.log('info', `Rule already exists, skipping: ${redirectCommand}`); - } else { - await execAsync(redirectCommand); - this.log('info', `Added NetworkProxy redirection rule: ${redirectCommand}`); - - this.rules.push({ - table, - chain: 'OUTPUT', - command: redirectCommand, - tag: `${this.ruleTag}:NETPROXY_REDIRECT`, - added: true - }); - } - } - - return true; - } catch (err) { - this.log('error', `Failed to set up NetworkProxy integration: ${err}`); - return false; - } - } - - /** - * Rolls back rules that were added in case of error - */ - 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 { - // Convert -A (add) to -D (delete) - const deleteCommand = rule.command.replace('-A', '-D'); - await execAsync(deleteCommand); - this.log('info', `Rolled back rule: ${deleteCommand}`); - - rule.added = false; - } catch (err) { - this.log('error', `Failed to roll back rule: ${err}`); - } - } - } - } - - /** - * Sets up iptables rules for port forwarding with enhanced features - */ - public async start(): Promise { - // Optionally clean the slate first - if (this.settings.forceCleanSlate) { - await IPTablesProxy.cleanSlate(); - } - - // First set up any custom chains - if (this.settings.addJumpRule) { - const chainSetupSuccess = await this.setupCustomChain(); - if (!chainSetupSuccess) { - throw new Error('Failed to set up custom chain'); - } - - // For IPv6 if enabled - if (this.settings.ipv6Support) { - const chainSetupSuccessIpv6 = await this.setupCustomChain(true); - if (!chainSetupSuccessIpv6) { - this.log('warn', 'Failed to set up IPv6 custom chain, continuing with IPv4 only'); - } - } - } - - // Add source IP filters - await this.addSourceIPFilter(); - if (this.settings.ipv6Support) { - await this.addSourceIPFilter(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); - } - } - - // 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 - for (const fromRange of fromPortRanges) { - await this.addPortForwardingRule(fromRange, toPortRanges[0]); - - if (this.settings.ipv6Support) { - await this.addPortForwardingRule(fromRange, toPortRanges[0], true); - } - } - } else { - throw new Error('Mismatched port counts: fromPort and toPort arrays must have equal length or toPort must be a single value'); - } - } else { - // Add port forwarding rules for each port specification - for (let i = 0; i < fromPortRanges.length; i++) { - await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i]); - - if (this.settings.ipv6Support) { - await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i], true); - } - } - } - - // Final check - ensure we have at least one rule added - if (this.rules.filter(r => r.added).length === 0) { - throw new Error('No rules were added'); - } - } - - /** - * Removes all added iptables rules - */ - public async stop(): 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 { - // Convert -A (add) to -D (delete) - const deleteCommand = rule.command.replace('-A', '-D'); - await execAsync(deleteCommand); - this.log('info', `Removed rule: ${deleteCommand}`); - - rule.added = false; - } catch (err) { - this.log('error', `Failed to remove rule: ${err}`); - } - } - } - - // If we created a custom chain, we need to clean it up - if (this.customChain) { - try { - // First flush the chain - await execAsync(`iptables -t nat -F ${this.customChain}`); - this.log('info', `Flushed custom chain: ${this.customChain}`); - - // Then delete it - await execAsync(`iptables -t nat -X ${this.customChain}`); - this.log('info', `Deleted custom chain: ${this.customChain}`); - - // Same for IPv6 if enabled - if (this.settings.ipv6Support) { - try { - await execAsync(`ip6tables -t nat -F ${this.customChain}`); - await execAsync(`ip6tables -t nat -X ${this.customChain}`); - this.log('info', `Deleted IPv6 custom chain: ${this.customChain}`); - } catch (err) { - this.log('error', `Failed to delete IPv6 custom chain: ${err}`); - } - } - } catch (err) { - this.log('error', `Failed to delete custom chain: ${err}`); - } - } - - // Clear rules array - this.rules = []; - } - - /** - * Synchronous version of stop, for use in exit handlers - */ - public stopSync(): void { - // 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 { - // Convert -A (add) to -D (delete) - const deleteCommand = rule.command.replace('-A', '-D'); - execSync(deleteCommand); - this.log('info', `Removed rule: ${deleteCommand}`); - - rule.added = false; - } catch (err) { - this.log('error', `Failed to remove rule: ${err}`); - } - } - } - - // If we created a custom chain, we need to clean it up - if (this.customChain) { - try { - // First flush the chain - execSync(`iptables -t nat -F ${this.customChain}`); - - // Then delete it - execSync(`iptables -t nat -X ${this.customChain}`); - this.log('info', `Deleted custom chain: ${this.customChain}`); - - // Same for IPv6 if enabled - if (this.settings.ipv6Support) { - try { - execSync(`ip6tables -t nat -F ${this.customChain}`); - execSync(`ip6tables -t nat -X ${this.customChain}`); - } catch (err) { - // IPv6 failures are non-critical - } - } - } catch (err) { - this.log('error', `Failed to delete custom chain: ${err}`); - } - } - - // Clear rules array - this.rules = []; - } - - /** - * Asynchronously cleans up any iptables rules in the nat table that were added by this module. - * It looks for rules with comments containing "IPTablesProxy:". - */ - public static async cleanSlate(): Promise { - await IPTablesProxy.cleanSlateInternal(); - - // Also clean IPv6 rules - await IPTablesProxy.cleanSlateInternal(true); - } - - /** - * Internal implementation of cleanSlate with IPv6 support - */ - private static async cleanSlateInternal(isIpv6: boolean = false): Promise { - const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables'; - - try { - const { stdout } = await execAsync(`${iptablesCmd}-save -t nat`); - const lines = stdout.split('\n'); - const proxyLines = lines.filter(line => line.includes('IPTablesProxy:')); - - // First, find and remove any custom chains - const customChains = new Set(); - const jumpRules: string[] = []; - - for (const line of proxyLines) { - if (line.includes('IPTablesProxy:JUMP')) { - // Extract chain name from jump rule - const match = line.match(/\s+-j\s+(\S+)\s+/); - if (match && match[1].startsWith('IPTablesProxy_')) { - customChains.add(match[1]); - jumpRules.push(line); - } - } - } - - // Remove jump rules first - for (const line of jumpRules) { - const trimmedLine = line.trim(); - if (trimmedLine.startsWith('-A')) { - // Replace the "-A" with "-D" to form a deletion command - const deleteRule = trimmedLine.replace('-A', '-D'); - const cmd = `${iptablesCmd} -t nat ${deleteRule}`; - try { - await execAsync(cmd); - console.log(`Cleaned up iptables jump rule: ${cmd}`); - } catch (err) { - console.error(`Failed to remove iptables jump rule: ${cmd}`, err); - } - } - } - - // Then remove all other rules - for (const line of proxyLines) { - if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled - const trimmedLine = line.trim(); - if (trimmedLine.startsWith('-A')) { - // Replace the "-A" with "-D" to form a deletion command - const deleteRule = trimmedLine.replace('-A', '-D'); - const cmd = `${iptablesCmd} -t nat ${deleteRule}`; - try { - await execAsync(cmd); - console.log(`Cleaned up iptables rule: ${cmd}`); - } catch (err) { - console.error(`Failed to remove iptables rule: ${cmd}`, err); - } - } - } - } - - // Finally clean up custom chains - for (const chain of customChains) { - try { - // Flush the chain - await execAsync(`${iptablesCmd} -t nat -F ${chain}`); - console.log(`Flushed custom chain: ${chain}`); - - // Delete the chain - await execAsync(`${iptablesCmd} -t nat -X ${chain}`); - console.log(`Deleted custom chain: ${chain}`); - } catch (err) { - console.error(`Failed to delete custom chain ${chain}:`, err); - } - } - } catch (err) { - console.error(`Failed to run ${iptablesCmd}-save: ${err}`); - } - } - - /** - * Synchronously cleans up any iptables rules in the nat table that were added by this module. - * It looks for rules with comments containing "IPTablesProxy:". - * This method is intended for use in process exit handlers. - */ - public static cleanSlateSync(): void { - IPTablesProxy.cleanSlateSyncInternal(); - - // Also clean IPv6 rules - IPTablesProxy.cleanSlateSyncInternal(true); - } - - /** - * Internal implementation of cleanSlateSync with IPv6 support - */ - private static cleanSlateSyncInternal(isIpv6: boolean = false): void { - const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables'; - - try { - const stdout = execSync(`${iptablesCmd}-save -t nat`).toString(); - const lines = stdout.split('\n'); - const proxyLines = lines.filter(line => line.includes('IPTablesProxy:')); - - // First, find and remove any custom chains - const customChains = new Set(); - const jumpRules: string[] = []; - - for (const line of proxyLines) { - if (line.includes('IPTablesProxy:JUMP')) { - // Extract chain name from jump rule - const match = line.match(/\s+-j\s+(\S+)\s+/); - if (match && match[1].startsWith('IPTablesProxy_')) { - customChains.add(match[1]); - jumpRules.push(line); - } - } - } - - // Remove jump rules first - for (const line of jumpRules) { - const trimmedLine = line.trim(); - if (trimmedLine.startsWith('-A')) { - // Replace the "-A" with "-D" to form a deletion command - const deleteRule = trimmedLine.replace('-A', '-D'); - const cmd = `${iptablesCmd} -t nat ${deleteRule}`; - try { - execSync(cmd); - console.log(`Cleaned up iptables jump rule: ${cmd}`); - } catch (err) { - console.error(`Failed to remove iptables jump rule: ${cmd}`, err); - } - } - } - - // Then remove all other rules - for (const line of proxyLines) { - if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled - const trimmedLine = line.trim(); - if (trimmedLine.startsWith('-A')) { - const deleteRule = trimmedLine.replace('-A', '-D'); - const cmd = `${iptablesCmd} -t nat ${deleteRule}`; - try { - execSync(cmd); - console.log(`Cleaned up iptables rule: ${cmd}`); - } catch (err) { - console.error(`Failed to remove iptables rule: ${cmd}`, err); - } - } - } - } - - // Finally clean up custom chains - for (const chain of customChains) { - try { - // Flush the chain - execSync(`${iptablesCmd} -t nat -F ${chain}`); - - // Delete the chain - execSync(`${iptablesCmd} -t nat -X ${chain}`); - console.log(`Deleted custom chain: ${chain}`); - } catch (err) { - console.error(`Failed to delete custom chain ${chain}:`, err); - } - } - } catch (err) { - console.error(`Failed to run ${iptablesCmd}-save: ${err}`); - } - } - - /** - * Logging utility that respects the enableLogging setting - */ - private log(level: 'info' | 'warn' | 'error', message: string): void { - if (!this.settings.enableLogging && level === 'info') { - return; - } - - const timestamp = new Date().toISOString(); - - switch (level) { - case 'info': - console.log(`[${timestamp}] [INFO] ${message}`); - break; - case 'warn': - console.warn(`[${timestamp}] [WARN] ${message}`); - break; - case 'error': - console.error(`[${timestamp}] [ERROR] ${message}`); - break; - } - } -} \ No newline at end of file diff --git a/ts/classes.nftablesproxy.ts b/ts/classes.nftablesproxy.ts new file mode 100644 index 0000000..b251ea4 --- /dev/null +++ b/ts/classes.nftablesproxy.ts @@ -0,0 +1,2045 @@ +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; + } + } +} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts index ad8ce43..04e92af 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,4 +1,4 @@ -export * from './classes.iptablesproxy.js'; +export * from './classes.nftablesproxy.js'; export * from './classes.networkproxy.js'; export * from './classes.port80handler.js'; export * from './classes.sslredirect.js';