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