2045 lines
73 KiB
TypeScript
2045 lines
73 KiB
TypeScript
import { exec, execSync } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
/**
|
|
* Custom error classes for better error handling
|
|
*/
|
|
export class NftBaseError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'NftBaseError';
|
|
}
|
|
}
|
|
|
|
export class NftValidationError extends NftBaseError {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'NftValidationError';
|
|
}
|
|
}
|
|
|
|
export class NftExecutionError extends NftBaseError {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'NftExecutionError';
|
|
}
|
|
}
|
|
|
|
export class NftResourceError extends NftBaseError {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'NftResourceError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents a port range for forwarding
|
|
*/
|
|
export interface IPortRange {
|
|
from: number;
|
|
to: number;
|
|
}
|
|
|
|
/**
|
|
* Settings for NfTablesProxy.
|
|
*/
|
|
export interface INfTableProxySettings {
|
|
// Basic settings
|
|
fromPort: number | IPortRange | Array<number | IPortRange>; // Support single port, port range, or multiple ports/ranges
|
|
toPort: number | IPortRange | Array<number | IPortRange>;
|
|
toHost?: string; // Target host for proxying; defaults to 'localhost'
|
|
|
|
// Advanced settings
|
|
preserveSourceIP?: boolean; // If true, the original source IP is preserved
|
|
deleteOnExit?: boolean; // If true, clean up rules before process exit
|
|
protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward, defaults to 'tcp'
|
|
enableLogging?: boolean; // Enable detailed logging
|
|
ipv6Support?: boolean; // Enable IPv6 support
|
|
logFormat?: 'plain' | 'json'; // Format for logs
|
|
|
|
// Source filtering
|
|
allowedSourceIPs?: string[]; // If provided, only these IPs are allowed
|
|
bannedSourceIPs?: string[]; // If provided, these IPs are blocked
|
|
useIPSets?: boolean; // Use nftables sets for efficient IP management
|
|
|
|
// Rule management
|
|
forceCleanSlate?: boolean; // Clear all NfTablesProxy rules before starting
|
|
tableName?: string; // Custom table name (defaults to 'portproxy')
|
|
|
|
// Connection management
|
|
maxRetries?: number; // Maximum number of retries for failed commands
|
|
retryDelayMs?: number; // Delay between retries in milliseconds
|
|
useAdvancedNAT?: boolean; // Use connection tracking for stateful NAT
|
|
|
|
// Quality of Service
|
|
qos?: {
|
|
enabled: boolean;
|
|
maxRate?: string; // e.g. "10mbps"
|
|
priority?: number; // 1 (highest) to 10 (lowest)
|
|
markConnections?: boolean; // Mark connections for easier management
|
|
};
|
|
|
|
// Integration with PortProxy/NetworkProxy
|
|
netProxyIntegration?: {
|
|
enabled: boolean;
|
|
redirectLocalhost?: boolean; // Redirect localhost traffic to NetworkProxy
|
|
sslTerminationPort?: number; // Port where NetworkProxy handles SSL termination
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Represents a rule added to nftables
|
|
*/
|
|
interface NfTablesRule {
|
|
handle?: number; // Rule handle for deletion
|
|
tableFamily: string; // 'ip' or 'ip6'
|
|
tableName: string; // Table name
|
|
chainName: string; // Chain name
|
|
ruleContents: string; // Rule definition
|
|
added: boolean; // Whether the rule was successfully added
|
|
verified?: boolean; // Whether the rule has been verified as applied
|
|
}
|
|
|
|
/**
|
|
* Interface for status reporting
|
|
*/
|
|
export interface INfTablesStatus {
|
|
active: boolean;
|
|
ruleCount: {
|
|
total: number;
|
|
added: number;
|
|
verified: number;
|
|
};
|
|
tablesConfigured: { family: string; tableName: string }[];
|
|
metrics: {
|
|
forwardedConnections?: number;
|
|
activeConnections?: number;
|
|
bytesForwarded?: {
|
|
sent: number;
|
|
received: number;
|
|
};
|
|
};
|
|
qosEnabled?: boolean;
|
|
ipSetsConfigured?: {
|
|
name: string;
|
|
elementCount: number;
|
|
type: string;
|
|
}[];
|
|
}
|
|
|
|
/**
|
|
* NfTablesProxy sets up nftables NAT rules to forward TCP traffic.
|
|
* Enhanced with multi-port support, IPv6, connection tracking, metrics,
|
|
* and more advanced features.
|
|
*/
|
|
export class NfTablesProxy {
|
|
public settings: INfTableProxySettings;
|
|
private rules: NfTablesRule[] = [];
|
|
private ipSets: Map<string, string[]> = new Map(); // Store IP sets for tracking
|
|
private ruleTag: string;
|
|
private tableName: string;
|
|
private tempFilePath: string;
|
|
private static NFT_CMD = 'nft';
|
|
|
|
constructor(settings: INfTableProxySettings) {
|
|
// Validate inputs to prevent command injection
|
|
this.validateSettings(settings);
|
|
|
|
// Set default settings
|
|
this.settings = {
|
|
...settings,
|
|
toHost: settings.toHost || 'localhost',
|
|
protocol: settings.protocol || 'tcp',
|
|
enableLogging: settings.enableLogging !== undefined ? settings.enableLogging : false,
|
|
ipv6Support: settings.ipv6Support !== undefined ? settings.ipv6Support : false,
|
|
tableName: settings.tableName || 'portproxy',
|
|
logFormat: settings.logFormat || 'plain',
|
|
useIPSets: settings.useIPSets !== undefined ? settings.useIPSets : true,
|
|
maxRetries: settings.maxRetries || 3,
|
|
retryDelayMs: settings.retryDelayMs || 1000,
|
|
useAdvancedNAT: settings.useAdvancedNAT !== undefined ? settings.useAdvancedNAT : false,
|
|
};
|
|
|
|
// Generate a unique identifier for the rules added by this instance
|
|
this.ruleTag = `NfTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`;
|
|
|
|
// Set table name
|
|
this.tableName = this.settings.tableName || 'portproxy';
|
|
|
|
// Create a temp file path for batch operations
|
|
this.tempFilePath = path.join(os.tmpdir(), `nft-rules-${Date.now()}.nft`);
|
|
|
|
// Register cleanup handlers if deleteOnExit is true
|
|
if (this.settings.deleteOnExit) {
|
|
const cleanup = () => {
|
|
try {
|
|
this.stopSync();
|
|
} catch (err) {
|
|
this.log('error', 'Error cleaning nftables rules on exit:', { error: err.message });
|
|
}
|
|
};
|
|
|
|
process.on('exit', cleanup);
|
|
process.on('SIGINT', () => {
|
|
cleanup();
|
|
process.exit();
|
|
});
|
|
process.on('SIGTERM', () => {
|
|
cleanup();
|
|
process.exit();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates settings to prevent command injection and ensure valid values
|
|
*/
|
|
private validateSettings(settings: INfTableProxySettings): void {
|
|
// Validate port numbers
|
|
const validatePorts = (port: number | IPortRange | Array<number | IPortRange>) => {
|
|
if (Array.isArray(port)) {
|
|
port.forEach(p => validatePorts(p));
|
|
return;
|
|
}
|
|
|
|
if (typeof port === 'number') {
|
|
if (port < 1 || port > 65535) {
|
|
throw new NftValidationError(`Invalid port number: ${port}`);
|
|
}
|
|
} else if (typeof port === 'object') {
|
|
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
|
|
throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
validatePorts(settings.fromPort);
|
|
validatePorts(settings.toPort);
|
|
|
|
// Define regex patterns for validation
|
|
const ipRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
|
|
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
|
|
|
|
// Validate IP addresses
|
|
const validateIPs = (ips?: string[]) => {
|
|
if (!ips) return;
|
|
|
|
for (const ip of ips) {
|
|
if (!ipRegex.test(ip) && !ipv6Regex.test(ip)) {
|
|
throw new NftValidationError(`Invalid IP address format: ${ip}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
validateIPs(settings.allowedSourceIPs);
|
|
validateIPs(settings.bannedSourceIPs);
|
|
|
|
// Validate toHost - only allow hostnames or IPs
|
|
if (settings.toHost) {
|
|
const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
|
|
if (!hostRegex.test(settings.toHost) && !ipRegex.test(settings.toHost) && !ipv6Regex.test(settings.toHost)) {
|
|
throw new NftValidationError(`Invalid host format: ${settings.toHost}`);
|
|
}
|
|
}
|
|
|
|
// Validate table name to prevent command injection
|
|
if (settings.tableName) {
|
|
const tableNameRegex = /^[a-zA-Z0-9_]+$/;
|
|
if (!tableNameRegex.test(settings.tableName)) {
|
|
throw new NftValidationError(`Invalid table name: ${settings.tableName}. Only alphanumeric characters and underscores are allowed.`);
|
|
}
|
|
}
|
|
|
|
// Validate QoS settings if enabled
|
|
if (settings.qos?.enabled) {
|
|
if (settings.qos.maxRate) {
|
|
const rateRegex = /^[0-9]+[kKmMgG]?bps$/;
|
|
if (!rateRegex.test(settings.qos.maxRate)) {
|
|
throw new NftValidationError(`Invalid rate format: ${settings.qos.maxRate}. Use format like "10mbps", "1gbps", etc.`);
|
|
}
|
|
}
|
|
|
|
if (settings.qos.priority !== undefined) {
|
|
if (settings.qos.priority < 1 || settings.qos.priority > 10 || !Number.isInteger(settings.qos.priority)) {
|
|
throw new NftValidationError(`Invalid priority: ${settings.qos.priority}. Must be an integer between 1 and 10.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalizes port specifications into an array of port ranges
|
|
*/
|
|
private normalizePortSpec(portSpec: number | IPortRange | Array<number | IPortRange>): IPortRange[] {
|
|
const result: IPortRange[] = [];
|
|
|
|
if (Array.isArray(portSpec)) {
|
|
// If it's an array, process each element
|
|
for (const spec of portSpec) {
|
|
result.push(...this.normalizePortSpec(spec));
|
|
}
|
|
} else if (typeof portSpec === 'number') {
|
|
// Single port becomes a range with the same start and end
|
|
result.push({ from: portSpec, to: portSpec });
|
|
} else {
|
|
// Already a range
|
|
result.push(portSpec);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Execute a command with retry capability
|
|
*/
|
|
private async executeWithRetry(command: string, maxRetries = 3, retryDelayMs = 1000): Promise<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
|
|
*/
|
|
private getAllPorts(portSpec: number | IPortRange | Array<number | IPortRange>): string {
|
|
const portRanges = this.normalizePortSpec(portSpec);
|
|
const ports: string[] = [];
|
|
|
|
for (const range of portRanges) {
|
|
if (range.from === range.to) {
|
|
ports.push(range.from.toString());
|
|
} else {
|
|
ports.push(`${range.from}-${range.to}`);
|
|
}
|
|
}
|
|
|
|
return ports.join(', ');
|
|
}
|
|
|
|
/**
|
|
* Configures advanced NAT with connection tracking
|
|
*/
|
|
private async setupAdvancedNAT(isIpv6: boolean = false): Promise<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,
|
|
fromPortRanges: IPortRange[],
|
|
toPortRange: IPortRange
|
|
): 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,
|
|
fromPortRanges: IPortRange[],
|
|
toPortRanges: IPortRange[]
|
|
): 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
|
|
*/
|
|
public async getStatus(): Promise<INfTablesStatus> {
|
|
const result: INfTablesStatus = {
|
|
active: this.rules.some(r => r.added),
|
|
ruleCount: {
|
|
total: this.rules.length,
|
|
added: this.rules.filter(r => r.added).length,
|
|
verified: this.rules.filter(r => r.verified).length
|
|
},
|
|
tablesConfigured: [],
|
|
metrics: {},
|
|
qosEnabled: this.settings.qos?.enabled || false
|
|
};
|
|
|
|
try {
|
|
// Get list of configured tables
|
|
const stdout = await this.executeWithRetry(
|
|
`${NfTablesProxy.NFT_CMD} list tables`,
|
|
this.settings.maxRetries,
|
|
this.settings.retryDelayMs
|
|
);
|
|
|
|
const tableRegex = /table (ip|ip6) (\w+)/g;
|
|
let match;
|
|
|
|
while ((match = tableRegex.exec(stdout)) !== null) {
|
|
const [, family, name] = match;
|
|
if (name === this.tableName) {
|
|
result.tablesConfigured.push({ family, tableName: name });
|
|
}
|
|
}
|
|
|
|
// Get system metrics
|
|
result.metrics = await this.getSystemMetrics();
|
|
|
|
// Get IP set status if using IP sets
|
|
if (this.settings.useIPSets) {
|
|
result.ipSetsConfigured = await this.getIPSetStatus();
|
|
}
|
|
|
|
return result;
|
|
} catch (err) {
|
|
this.log('error', `Failed to get status: ${err.message}`);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Performs a dry run to see what commands would be executed without actually applying them
|
|
*/
|
|
public async dryRun(): Promise<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;
|
|
}
|
|
}
|
|
} |