2025-03-18 21:55:09 +00:00
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' ;
2025-05-09 21:21:28 +00:00
import {
NftBaseError ,
NftValidationError ,
NftExecutionError ,
NftResourceError
} from './models/index.js' ;
import type {
PortRange ,
NfTableProxyOptions ,
NfTablesStatus
} from './models/index.js' ;
2025-03-18 21:55:09 +00:00
const execAsync = promisify ( exec ) ;
/ * *
* 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
}
/ * *
* 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 {
2025-05-09 21:21:28 +00:00
public settings : NfTableProxyOptions ;
2025-03-18 21:55:09 +00:00
private rules : NfTablesRule [ ] = [ ] ;
private ipSets : Map < string , string [ ] > = new Map ( ) ; // Store IP sets for tracking
private ruleTag : string ;
private tableName : string ;
private tempFilePath : string ;
private static NFT_CMD = 'nft' ;
2025-05-09 21:21:28 +00:00
constructor ( settings : NfTableProxyOptions ) {
2025-03-18 21:55:09 +00:00
// 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
* /
2025-05-09 21:21:28 +00:00
private validateSettings ( settings : NfTableProxyOptions ) : void {
2025-03-18 21:55:09 +00:00
// Validate port numbers
2025-05-09 21:21:28 +00:00
const validatePorts = ( port : number | PortRange | Array < number | PortRange > ) = > {
2025-03-18 21:55:09 +00:00
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
* /
2025-05-09 21:21:28 +00:00
private normalizePortSpec ( portSpec : number | PortRange | Array < number | PortRange > ) : PortRange [ ] {
const result : PortRange [ ] = [ ] ;
2025-03-18 21:55:09 +00:00
if ( Array . isArray ( portSpec ) ) {
// If it's an array, process each element
for ( const spec of portSpec ) {
result . push ( . . . this . normalizePortSpec ( spec ) ) ;
}
} else if ( typeof portSpec === 'number' ) {
// Single port becomes a range with the same start and end
result . push ( { from : portSpec , to : portSpec } ) ;
} else {
// Already a range
result . push ( portSpec ) ;
}
return result ;
}
/ * *
* Execute a command with retry capability
* /
private async executeWithRetry ( command : string , maxRetries = 3 , retryDelayMs = 1000 ) : Promise < string > {
let lastError : Error | undefined ;
for ( let i = 0 ; i < maxRetries ; i ++ ) {
try {
const { stdout } = await execAsync ( command ) ;
return stdout ;
} catch ( err ) {
lastError = err ;
this . log ( 'warn' , ` Command failed (attempt ${ i + 1 } / ${ maxRetries } ): ${ command } ` , { error : err.message } ) ;
// Wait before retry, unless it's the last attempt
if ( i < maxRetries - 1 ) {
await 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 < boolean > {
try {
await this . executeWithRetry ( ` ${ NfTablesProxy . NFT_CMD } --version ` , this . settings . maxRetries , this . settings . retryDelayMs ) ;
// Check for conntrack support if we're using advanced NAT
if ( this . settings . useAdvancedNAT ) {
try {
await this . executeWithRetry ( 'lsmod | grep nf_conntrack' , this . settings . maxRetries , this . settings . retryDelayMs ) ;
} catch ( err ) {
this . log ( 'warn' , 'Connection tracking modules might not be loaded, advanced NAT features may not work' ) ;
}
}
return true ;
} catch ( err ) {
this . log ( 'error' , ` nftables is not available: ${ err . message } ` ) ;
return false ;
}
}
/ * *
* Creates the necessary tables and chains
* /
private async setupTablesAndChains ( isIpv6 : boolean = false ) : Promise < boolean > {
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 < boolean > {
try {
// Filter IPs based on family
const filteredIPs = ips . filter ( ip = > {
if ( family === 'ip6' && ip . includes ( ':' ) ) return true ;
if ( family === 'ip' && ip . includes ( '.' ) ) return true ;
return false ;
} ) ;
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 < boolean > {
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
* /
2025-05-09 21:21:28 +00:00
private getAllPorts ( portSpec : number | PortRange | Array < number | PortRange > ) : string {
2025-03-18 21:55:09 +00:00
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 < boolean > {
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 < boolean > {
// 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 ,
2025-05-09 21:21:28 +00:00
fromPortRanges : PortRange [ ] ,
toPortRange : PortRange
2025-03-18 21:55:09 +00:00
) : Promise < boolean > {
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 ,
2025-05-09 21:21:28 +00:00
fromPortRanges : PortRange [ ] ,
toPortRanges : PortRange [ ]
2025-03-18 21:55:09 +00:00
) : Promise < boolean > {
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 < boolean > {
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 < boolean > {
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 < boolean > {
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 < 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 {
// 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 < boolean > {
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
* /
2025-05-09 21:21:28 +00:00
public async getStatus ( ) : Promise < NfTablesStatus > {
const result : NfTablesStatus = {
2025-03-18 21:55:09 +00:00
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 < string [ ] > {
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 < void > {
// 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 < 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
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 < 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 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 < void > {
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 < string , any > ) : 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 ;
}
}
}