feat(proxies): introduce nftables command executor and utilities, default certificate provider, expanded route/socket helper modules, and security improvements
This commit is contained in:
@@ -3,3 +3,4 @@
|
||||
*/
|
||||
export * from './nftables-proxy.js';
|
||||
export * from './models/index.js';
|
||||
export * from './utils/index.js';
|
||||
|
||||
@@ -3,10 +3,8 @@ 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
|
||||
@@ -16,6 +14,12 @@ import type {
|
||||
NfTableProxyOptions,
|
||||
NfTablesStatus
|
||||
} from './models/index.js';
|
||||
import {
|
||||
NftCommandExecutor,
|
||||
normalizePortSpec,
|
||||
validateSettings,
|
||||
filterIPsByFamily
|
||||
} from './utils/index.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -44,11 +48,12 @@ export class NfTablesProxy {
|
||||
private ruleTag: string;
|
||||
private tableName: string;
|
||||
private tempFilePath: string;
|
||||
private executor: NftCommandExecutor;
|
||||
private static NFT_CMD = 'nft';
|
||||
|
||||
constructor(settings: NfTableProxyOptions) {
|
||||
// Validate inputs to prevent command injection
|
||||
this.validateSettings(settings);
|
||||
validateSettings(settings);
|
||||
|
||||
// Set default settings
|
||||
this.settings = {
|
||||
@@ -74,6 +79,16 @@ export class NfTablesProxy {
|
||||
// Create a temp file path for batch operations
|
||||
this.tempFilePath = path.join(os.tmpdir(), `nft-rules-${Date.now()}.nft`);
|
||||
|
||||
// Create the command executor
|
||||
this.executor = new NftCommandExecutor(
|
||||
(level, message, data) => this.log(level, message, data),
|
||||
{
|
||||
maxRetries: this.settings.maxRetries,
|
||||
retryDelayMs: this.settings.retryDelayMs,
|
||||
tempFilePath: this.tempFilePath
|
||||
}
|
||||
);
|
||||
|
||||
// Register cleanup handlers if deleteOnExit is true
|
||||
if (this.settings.deleteOnExit) {
|
||||
// Synchronous cleanup for 'exit' event (only sync code runs here)
|
||||
@@ -104,183 +119,17 @@ export class NfTablesProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates settings to prevent command injection and ensure valid values
|
||||
*/
|
||||
private validateSettings(settings: NfTableProxyOptions): void {
|
||||
// Validate port numbers
|
||||
const validatePorts = (port: number | PortRange | Array<number | PortRange>) => {
|
||||
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.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
|
||||
*/
|
||||
private normalizePortSpec(portSpec: number | PortRange | Array<number | PortRange>): PortRange[] {
|
||||
const result: PortRange[] = [];
|
||||
|
||||
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 delay(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute system command synchronously (single attempt, no retry)
|
||||
* Used only for exit handlers where the process is terminating anyway.
|
||||
* For normal operations, use the async executeWithRetry method.
|
||||
*/
|
||||
private executeSync(command: string): string {
|
||||
try {
|
||||
return execSync(command, { timeout: 5000 }).toString();
|
||||
} catch (err) {
|
||||
this.log('warn', `Sync command failed: ${command}`, { error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private async executeWithTempFile(rulesetContent: string): Promise<void> {
|
||||
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
|
||||
*/
|
||||
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;
|
||||
const available = await this.executor.checkAvailability();
|
||||
|
||||
if (available && this.settings.useAdvancedNAT) {
|
||||
await this.executor.checkConntrackModules();
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,7 +140,7 @@ export class NfTablesProxy {
|
||||
|
||||
try {
|
||||
// Check if the table already exists
|
||||
const stdout = await this.executeWithRetry(
|
||||
const stdout = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -301,7 +150,7 @@ export class NfTablesProxy {
|
||||
|
||||
if (!tableExists) {
|
||||
// Create the table
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add table ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -310,7 +159,7 @@ export class NfTablesProxy {
|
||||
this.log('info', `Created table ${family} ${this.tableName}`);
|
||||
|
||||
// Create the nat chain for the prerouting hook
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_prerouting { type nat hook prerouting priority -100 ; }`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -320,7 +169,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Create the nat chain for the postrouting hook if not preserving source IP
|
||||
if (!this.settings.preserveSourceIP) {
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_postrouting { type nat hook postrouting priority 100 ; }`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -331,7 +180,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Create the chain for NetworkProxy integration if needed
|
||||
if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) {
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_output { type nat hook output priority 0 ; }`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -342,7 +191,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Create the QoS chain if needed
|
||||
if (this.settings.qos?.enabled) {
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} qos_forward { type filter hook forward priority 0 ; }`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -372,11 +221,7 @@ export class NfTablesProxy {
|
||||
): 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;
|
||||
});
|
||||
const filteredIPs = filterIPsByFamily(ips, family as 'ip' | 'ip6');
|
||||
|
||||
if (filteredIPs.length === 0) {
|
||||
this.log('info', `No IP addresses of type ${setType} to add to set ${setName}`);
|
||||
@@ -385,7 +230,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Check if set already exists
|
||||
try {
|
||||
const sets = await this.executeWithRetry(
|
||||
const sets = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -395,7 +240,7 @@ export class NfTablesProxy {
|
||||
this.log('info', `IP set ${setName} already exists, will add elements`);
|
||||
} else {
|
||||
// Create the set
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -405,7 +250,7 @@ export class NfTablesProxy {
|
||||
}
|
||||
} catch (err) {
|
||||
// Set might not exist yet, create it
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -420,7 +265,7 @@ export class NfTablesProxy {
|
||||
const batch = filteredIPs.slice(i, i + batchSize);
|
||||
const elements = batch.join(', ');
|
||||
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} add element ${family} ${this.tableName} ${setName} { ${elements} }`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -563,7 +408,7 @@ export class NfTablesProxy {
|
||||
// Only write and apply if we have rules to add
|
||||
if (rulesetContent) {
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
await this.executor.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added source IP filter rules for ${family}`);
|
||||
|
||||
@@ -593,7 +438,7 @@ export class NfTablesProxy {
|
||||
* Gets a comma-separated list of all ports from a port specification
|
||||
*/
|
||||
private getAllPorts(portSpec: number | PortRange | Array<number | PortRange>): string {
|
||||
const portRanges = this.normalizePortSpec(portSpec);
|
||||
const portRanges = normalizePortSpec(portSpec);
|
||||
const ports: string[] = [];
|
||||
|
||||
for (const range of portRanges) {
|
||||
@@ -620,8 +465,8 @@ export class NfTablesProxy {
|
||||
|
||||
try {
|
||||
// Get the port ranges
|
||||
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
|
||||
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
|
||||
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
|
||||
const toPortRanges = normalizePortSpec(this.settings.toPort);
|
||||
|
||||
let rulesetContent = '';
|
||||
|
||||
@@ -670,7 +515,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the rules if we have any
|
||||
if (rulesetContent) {
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
await this.executor.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added advanced NAT rules for ${family}`);
|
||||
|
||||
@@ -708,8 +553,8 @@ export class NfTablesProxy {
|
||||
|
||||
try {
|
||||
// Normalize port specifications
|
||||
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
|
||||
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
|
||||
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
|
||||
const toPortRanges = normalizePortSpec(this.settings.toPort);
|
||||
|
||||
// Handle the case where fromPort and toPort counts don't match
|
||||
if (fromPortRanges.length !== toPortRanges.length) {
|
||||
@@ -815,7 +660,7 @@ export class NfTablesProxy {
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
await this.executor.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added port forwarding rules for ${family}`);
|
||||
|
||||
@@ -919,7 +764,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
await this.executor.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added port forwarding rules for ${family}`);
|
||||
|
||||
@@ -972,7 +817,7 @@ export class NfTablesProxy {
|
||||
// Add priority marking if specified
|
||||
if (this.settings.qos.priority !== undefined) {
|
||||
// Check if the chain exists
|
||||
const chainsOutput = await this.executeWithRetry(
|
||||
const chainsOutput = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list chains ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -988,7 +833,7 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
// Add the rules to mark packets with this priority
|
||||
for (const range of this.normalizePortSpec(this.settings.toPort)) {
|
||||
for (const range of 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`;
|
||||
|
||||
@@ -1005,7 +850,7 @@ export class NfTablesProxy {
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
await this.executor.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added QoS rules for ${family}`);
|
||||
|
||||
@@ -1048,7 +893,7 @@ export class NfTablesProxy {
|
||||
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(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} ${rule}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1091,7 +936,7 @@ export class NfTablesProxy {
|
||||
const commentTag = commentMatch[1];
|
||||
|
||||
// List the chain to check if our rule is there
|
||||
const stdout = await this.executeWithRetry(
|
||||
const stdout = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list chain ${tableFamily} ${tableName} ${chainName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1127,7 +972,7 @@ export class NfTablesProxy {
|
||||
try {
|
||||
// For nftables, create a delete rule by replacing 'add' with 'delete'
|
||||
const deleteRule = rule.ruleContents.replace('add rule', 'delete rule');
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} ${deleteRule}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1149,7 +994,7 @@ export class NfTablesProxy {
|
||||
*/
|
||||
private async tableExists(family: string, tableName: string): Promise<boolean> {
|
||||
try {
|
||||
const stdout = await this.executeWithRetry(
|
||||
const stdout = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1178,7 +1023,7 @@ export class NfTablesProxy {
|
||||
try {
|
||||
// Try to get connection metrics if conntrack is available
|
||||
try {
|
||||
const stdout = await this.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs);
|
||||
const stdout = await this.executor.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs);
|
||||
metrics.activeConnections = parseInt(stdout.trim(), 10);
|
||||
} catch (err) {
|
||||
// conntrack not available, skip this metric
|
||||
@@ -1187,7 +1032,7 @@ export class NfTablesProxy {
|
||||
// Try to get forwarded connections count from nftables counters
|
||||
try {
|
||||
// Look for counters in our rules
|
||||
const stdout = await this.executeWithRetry(
|
||||
const stdout = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list table ip ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1238,7 +1083,7 @@ export class NfTablesProxy {
|
||||
try {
|
||||
for (const family of ['ip', 'ip6']) {
|
||||
try {
|
||||
const stdout = await this.executeWithRetry(
|
||||
const stdout = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1290,7 +1135,7 @@ export class NfTablesProxy {
|
||||
|
||||
try {
|
||||
// Get list of configured tables
|
||||
const stdout = await this.executeWithRetry(
|
||||
const stdout = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list tables`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1396,8 +1241,8 @@ export class NfTablesProxy {
|
||||
// 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);
|
||||
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
|
||||
const toPortRanges = normalizePortSpec(this.settings.toPort);
|
||||
|
||||
if (fromPortRanges.length === 1 && toPortRanges.length === 1) {
|
||||
const fromRange = fromPortRanges[0];
|
||||
@@ -1413,8 +1258,8 @@ export class NfTablesProxy {
|
||||
}
|
||||
} else {
|
||||
// Standard NAT rules
|
||||
const fromRanges = this.normalizePortSpec(this.settings.fromPort);
|
||||
const toRanges = this.normalizePortSpec(this.settings.toPort);
|
||||
const fromRanges = normalizePortSpec(this.settings.fromPort);
|
||||
const toRanges = normalizePortSpec(this.settings.toPort);
|
||||
|
||||
if (fromRanges.length === 1 && toRanges.length === 1) {
|
||||
const fromRange = fromRanges[0];
|
||||
@@ -1460,7 +1305,7 @@ export class NfTablesProxy {
|
||||
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)) {
|
||||
for (const range of 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"`);
|
||||
}
|
||||
}
|
||||
@@ -1586,7 +1431,7 @@ export class NfTablesProxy {
|
||||
|
||||
try {
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1611,7 +1456,7 @@ export class NfTablesProxy {
|
||||
const [family, setName] = key.split(':');
|
||||
|
||||
try {
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1661,7 +1506,7 @@ export class NfTablesProxy {
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset (single attempt, no retry - process is exiting)
|
||||
this.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
|
||||
this.executor.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
|
||||
|
||||
this.log('info', 'Removed all added rules');
|
||||
|
||||
@@ -1685,7 +1530,7 @@ export class NfTablesProxy {
|
||||
const [family, setName] = key.split(':');
|
||||
|
||||
try {
|
||||
this.executeSync(
|
||||
this.executor.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`
|
||||
);
|
||||
} catch {
|
||||
@@ -1722,7 +1567,7 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
// Check if the table has any rules
|
||||
const stdout = await this.executeWithRetry(
|
||||
const stdout = await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1732,7 +1577,7 @@ export class NfTablesProxy {
|
||||
|
||||
if (!hasRules) {
|
||||
// Table is empty, delete it
|
||||
await this.executeWithRetry(
|
||||
await this.executor.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
@@ -1759,7 +1604,7 @@ export class NfTablesProxy {
|
||||
|
||||
try {
|
||||
// Check if table exists
|
||||
const tableExistsOutput = this.executeSync(
|
||||
const tableExistsOutput = this.executor.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`
|
||||
);
|
||||
|
||||
@@ -1770,7 +1615,7 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
// Check if the table has any rules
|
||||
const stdout = this.executeSync(
|
||||
const stdout = this.executor.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`
|
||||
);
|
||||
|
||||
@@ -1778,7 +1623,7 @@ export class NfTablesProxy {
|
||||
|
||||
if (!hasRules) {
|
||||
// Table is empty, delete it
|
||||
this.executeSync(
|
||||
this.executor.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`
|
||||
);
|
||||
|
||||
|
||||
38
ts/proxies/nftables-proxy/utils/index.ts
Normal file
38
ts/proxies/nftables-proxy/utils/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* NFTables Proxy Utilities
|
||||
*
|
||||
* This module exports utility functions and classes for NFTables operations.
|
||||
*/
|
||||
|
||||
// Command execution
|
||||
export { NftCommandExecutor } from './nft-command-executor.js';
|
||||
export type { INftLoggerFn, INftExecutorOptions } from './nft-command-executor.js';
|
||||
|
||||
// Port specification normalization
|
||||
export {
|
||||
normalizePortSpec,
|
||||
validatePorts,
|
||||
formatPortRange,
|
||||
portSpecToNftExpr,
|
||||
rangesOverlap,
|
||||
mergeOverlappingRanges,
|
||||
countPorts,
|
||||
isPortInSpec
|
||||
} from './nft-port-spec-normalizer.js';
|
||||
|
||||
// Rule validation
|
||||
export {
|
||||
isValidIP,
|
||||
isValidIPv4,
|
||||
isValidIPv6,
|
||||
isValidHostname,
|
||||
isValidTableName,
|
||||
isValidRate,
|
||||
validateIPs,
|
||||
validateHost,
|
||||
validateTableName,
|
||||
validateQosSettings,
|
||||
validateSettings,
|
||||
isIPForFamily,
|
||||
filterIPsByFamily
|
||||
} from './nft-rule-validator.js';
|
||||
162
ts/proxies/nftables-proxy/utils/nft-command-executor.ts
Normal file
162
ts/proxies/nftables-proxy/utils/nft-command-executor.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* NFTables Command Executor
|
||||
*
|
||||
* Handles command execution with retry logic, temp file management,
|
||||
* and error handling for nftables operations.
|
||||
*/
|
||||
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { delay } from '../../../core/utils/async-utils.js';
|
||||
import { AsyncFileSystem } from '../../../core/utils/fs-utils.js';
|
||||
import { NftExecutionError } from '../models/index.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface INftLoggerFn {
|
||||
(level: 'info' | 'warn' | 'error' | 'debug', message: string, data?: Record<string, any>): void;
|
||||
}
|
||||
|
||||
export interface INftExecutorOptions {
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
tempFilePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFTables command executor with retry logic and temp file support
|
||||
*/
|
||||
export class NftCommandExecutor {
|
||||
private static readonly NFT_CMD = 'nft';
|
||||
private maxRetries: number;
|
||||
private retryDelayMs: number;
|
||||
private tempFilePath: string;
|
||||
|
||||
constructor(
|
||||
private log: INftLoggerFn,
|
||||
options: INftExecutorOptions = {}
|
||||
) {
|
||||
this.maxRetries = options.maxRetries || 3;
|
||||
this.retryDelayMs = options.retryDelayMs || 1000;
|
||||
this.tempFilePath = options.tempFilePath || `/tmp/nft-rules-${Date.now()}.nft`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command with retry capability
|
||||
*/
|
||||
async executeWithRetry(command: string, maxRetries?: number, retryDelayMs?: number): Promise<string> {
|
||||
const retries = maxRetries ?? this.maxRetries;
|
||||
const delayMs = retryDelayMs ?? this.retryDelayMs;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const { stdout } = await execAsync(command);
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
this.log('warn', `Command failed (attempt ${i+1}/${retries}): ${command}`, { error: lastError.message });
|
||||
|
||||
// Wait before retry, unless it's the last attempt
|
||||
if (i < retries - 1) {
|
||||
await delay(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new NftExecutionError(`Failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute system command synchronously (single attempt, no retry)
|
||||
* Used only for exit handlers where the process is terminating anyway.
|
||||
*/
|
||||
executeSync(command: string): string {
|
||||
try {
|
||||
return execSync(command, { timeout: 5000 }).toString();
|
||||
} catch (err) {
|
||||
this.log('warn', `Sync command failed: ${command}`, { error: (err as Error).message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute nftables commands with a temporary file
|
||||
*/
|
||||
async executeWithTempFile(rulesetContent: string): Promise<void> {
|
||||
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
|
||||
|
||||
try {
|
||||
await this.executeWithRetry(
|
||||
`${NftCommandExecutor.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.maxRetries,
|
||||
this.retryDelayMs
|
||||
);
|
||||
} finally {
|
||||
// Always clean up the temp file
|
||||
await AsyncFileSystem.remove(this.tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if nftables is available
|
||||
*/
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
try {
|
||||
await this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} --version`, this.maxRetries, this.retryDelayMs);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.log('error', `nftables is not available: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection tracking modules are loaded
|
||||
*/
|
||||
async checkConntrackModules(): Promise<boolean> {
|
||||
try {
|
||||
await this.executeWithRetry('lsmod | grep nf_conntrack', this.maxRetries, this.retryDelayMs);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an nft command directly
|
||||
*/
|
||||
async nft(args: string): Promise<string> {
|
||||
return this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} ${args}`, this.maxRetries, this.retryDelayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an nft command synchronously (for cleanup on exit)
|
||||
*/
|
||||
nftSync(args: string): string {
|
||||
return this.executeSync(`${NftCommandExecutor.NFT_CMD} ${args}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the NFT command path
|
||||
*/
|
||||
static get nftCmd(): string {
|
||||
return NftCommandExecutor.NFT_CMD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the temp file path
|
||||
*/
|
||||
setTempFilePath(path: string): void {
|
||||
this.tempFilePath = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update retry settings
|
||||
*/
|
||||
setRetryOptions(maxRetries: number, retryDelayMs: number): void {
|
||||
this.maxRetries = maxRetries;
|
||||
this.retryDelayMs = retryDelayMs;
|
||||
}
|
||||
}
|
||||
125
ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts
Normal file
125
ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* NFTables Port Specification Normalizer
|
||||
*
|
||||
* Handles normalization and validation of port specifications
|
||||
* for nftables rules.
|
||||
*/
|
||||
|
||||
import type { PortRange } from '../models/index.js';
|
||||
import { NftValidationError } from '../models/index.js';
|
||||
|
||||
/**
|
||||
* Normalizes port specifications into an array of port ranges
|
||||
*/
|
||||
export function normalizePortSpec(portSpec: number | PortRange | Array<number | PortRange>): PortRange[] {
|
||||
const result: PortRange[] = [];
|
||||
|
||||
if (Array.isArray(portSpec)) {
|
||||
// If it's an array, process each element
|
||||
for (const spec of portSpec) {
|
||||
result.push(...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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates port numbers or ranges
|
||||
*/
|
||||
export function validatePorts(port: number | PortRange | Array<number | PortRange>): void {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format port range for nftables rule
|
||||
*/
|
||||
export function formatPortRange(range: PortRange): string {
|
||||
if (range.from === range.to) {
|
||||
return String(range.from);
|
||||
}
|
||||
return `${range.from}-${range.to}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert port spec to nftables expression
|
||||
*/
|
||||
export function portSpecToNftExpr(portSpec: number | PortRange | Array<number | PortRange>): string {
|
||||
const ranges = normalizePortSpec(portSpec);
|
||||
|
||||
if (ranges.length === 1) {
|
||||
return formatPortRange(ranges[0]);
|
||||
}
|
||||
|
||||
// Multiple ports/ranges need to use a set
|
||||
const ports = ranges.map(formatPortRange);
|
||||
return `{ ${ports.join(', ')} }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two port ranges overlap
|
||||
*/
|
||||
export function rangesOverlap(range1: PortRange, range2: PortRange): boolean {
|
||||
return range1.from <= range2.to && range2.from <= range1.to;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge overlapping port ranges
|
||||
*/
|
||||
export function mergeOverlappingRanges(ranges: PortRange[]): PortRange[] {
|
||||
if (ranges.length <= 1) return ranges;
|
||||
|
||||
// Sort by start port
|
||||
const sorted = [...ranges].sort((a, b) => a.from - b.from);
|
||||
const merged: PortRange[] = [sorted[0]];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const current = sorted[i];
|
||||
const lastMerged = merged[merged.length - 1];
|
||||
|
||||
if (current.from <= lastMerged.to + 1) {
|
||||
// Ranges overlap or are adjacent, merge them
|
||||
lastMerged.to = Math.max(lastMerged.to, current.to);
|
||||
} else {
|
||||
// No overlap, add as new range
|
||||
merged.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total number of ports in a port specification
|
||||
*/
|
||||
export function countPorts(portSpec: number | PortRange | Array<number | PortRange>): number {
|
||||
const ranges = normalizePortSpec(portSpec);
|
||||
return ranges.reduce((total, range) => total + (range.to - range.from + 1), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is within the given specification
|
||||
*/
|
||||
export function isPortInSpec(port: number, portSpec: number | PortRange | Array<number | PortRange>): boolean {
|
||||
const ranges = normalizePortSpec(portSpec);
|
||||
return ranges.some(range => port >= range.from && port <= range.to);
|
||||
}
|
||||
156
ts/proxies/nftables-proxy/utils/nft-rule-validator.ts
Normal file
156
ts/proxies/nftables-proxy/utils/nft-rule-validator.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* NFTables Rule Validator
|
||||
*
|
||||
* Handles validation of settings and inputs for nftables operations.
|
||||
* Prevents command injection and ensures valid values.
|
||||
*/
|
||||
|
||||
import type { PortRange, NfTableProxyOptions } from '../models/index.js';
|
||||
import { NftValidationError } from '../models/index.js';
|
||||
import { validatePorts } from './nft-port-spec-normalizer.js';
|
||||
|
||||
// IP address validation patterns
|
||||
const IPV4_REGEX = /^(([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 IPV6_REGEX = /^(([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]))?$/;
|
||||
const HOSTNAME_REGEX = /^(([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])$/;
|
||||
const TABLE_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
const RATE_REGEX = /^[0-9]+[kKmMgG]?bps$/;
|
||||
|
||||
/**
|
||||
* Validates an IP address (IPv4 or IPv6)
|
||||
*/
|
||||
export function isValidIP(ip: string): boolean {
|
||||
return IPV4_REGEX.test(ip) || IPV6_REGEX.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an IPv4 address
|
||||
*/
|
||||
export function isValidIPv4(ip: string): boolean {
|
||||
return IPV4_REGEX.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an IPv6 address
|
||||
*/
|
||||
export function isValidIPv6(ip: string): boolean {
|
||||
return IPV6_REGEX.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a hostname
|
||||
*/
|
||||
export function isValidHostname(hostname: string): boolean {
|
||||
return HOSTNAME_REGEX.test(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a table name for nftables
|
||||
*/
|
||||
export function isValidTableName(tableName: string): boolean {
|
||||
return TABLE_NAME_REGEX.test(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a rate specification (e.g., "10mbps")
|
||||
*/
|
||||
export function isValidRate(rate: string): boolean {
|
||||
return RATE_REGEX.test(rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an array of IP addresses
|
||||
*/
|
||||
export function validateIPs(ips?: string[]): void {
|
||||
if (!ips) return;
|
||||
|
||||
for (const ip of ips) {
|
||||
if (!isValidIP(ip)) {
|
||||
throw new NftValidationError(`Invalid IP address format: ${ip}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a host (can be hostname or IP)
|
||||
*/
|
||||
export function validateHost(host?: string): void {
|
||||
if (!host) return;
|
||||
|
||||
if (!isValidHostname(host) && !isValidIP(host)) {
|
||||
throw new NftValidationError(`Invalid host format: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a table name
|
||||
*/
|
||||
export function validateTableName(tableName?: string): void {
|
||||
if (!tableName) return;
|
||||
|
||||
if (!isValidTableName(tableName)) {
|
||||
throw new NftValidationError(
|
||||
`Invalid table name: ${tableName}. Only alphanumeric characters and underscores are allowed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates QoS settings
|
||||
*/
|
||||
export function validateQosSettings(qos?: NfTableProxyOptions['qos']): void {
|
||||
if (!qos?.enabled) return;
|
||||
|
||||
if (qos.maxRate && !isValidRate(qos.maxRate)) {
|
||||
throw new NftValidationError(
|
||||
`Invalid rate format: ${qos.maxRate}. Use format like "10mbps", "1gbps", etc.`
|
||||
);
|
||||
}
|
||||
|
||||
if (qos.priority !== undefined) {
|
||||
if (qos.priority < 1 || qos.priority > 10 || !Number.isInteger(qos.priority)) {
|
||||
throw new NftValidationError(
|
||||
`Invalid priority: ${qos.priority}. Must be an integer between 1 and 10.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all NfTablesProxy settings
|
||||
*/
|
||||
export function validateSettings(settings: NfTableProxyOptions): void {
|
||||
// Validate port numbers
|
||||
validatePorts(settings.fromPort);
|
||||
validatePorts(settings.toPort);
|
||||
|
||||
// Validate IP addresses
|
||||
validateIPs(settings.ipAllowList);
|
||||
validateIPs(settings.ipBlockList);
|
||||
|
||||
// Validate target host
|
||||
validateHost(settings.toHost);
|
||||
|
||||
// Validate table name
|
||||
validateTableName(settings.tableName);
|
||||
|
||||
// Validate QoS settings
|
||||
validateQosSettings(settings.qos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches the given family (ip or ip6)
|
||||
*/
|
||||
export function isIPForFamily(ip: string, family: 'ip' | 'ip6'): boolean {
|
||||
if (family === 'ip6') {
|
||||
return ip.includes(':');
|
||||
}
|
||||
return ip.includes('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter IPs by family
|
||||
*/
|
||||
export function filterIPsByFamily(ips: string[], family: 'ip' | 'ip6'): string[] {
|
||||
return ips.filter(ip => isIPForFamily(ip, family));
|
||||
}
|
||||
Reference in New Issue
Block a user