feat(proxies): introduce nftables command executor and utilities, default certificate provider, expanded route/socket helper modules, and security improvements

This commit is contained in:
2026-01-30 04:06:32 +00:00
parent f25be4c55a
commit 9697ab3078
27 changed files with 2453 additions and 2048 deletions

View 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';

View 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;
}
}

View 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);
}

View 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));
}