feat(nftables):add nftables support for nftables
This commit is contained in:
parent
cf96ff8a47
commit
a2e3e38025
214
examples/nftables-integration.ts
Normal file
214
examples/nftables-integration.ts
Normal file
@ -0,0 +1,214 @@
|
||||
/**
|
||||
* NFTables Integration Example
|
||||
*
|
||||
* This example demonstrates how to use the NFTables forwarding engine with SmartProxy
|
||||
* for high-performance network routing that operates at the kernel level.
|
||||
*
|
||||
* NOTE: This requires elevated privileges to run (sudo) as it interacts with nftables.
|
||||
*/
|
||||
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import {
|
||||
createNfTablesRoute,
|
||||
createNfTablesTerminateRoute,
|
||||
createCompleteNfTablesHttpsServer
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
// Simple NFTables-based HTTP forwarding example
|
||||
async function simpleForwardingExample() {
|
||||
console.log('Starting simple NFTables forwarding example...');
|
||||
|
||||
// Create a SmartProxy instance with a simple NFTables route
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesRoute('example.com', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: 80,
|
||||
protocol: 'tcp',
|
||||
preserveSourceIP: true,
|
||||
tableName: 'smartproxy_example'
|
||||
})
|
||||
],
|
||||
enableDetailedLogging: true
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('NFTables proxy started. Press Ctrl+C to stop.');
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Stopping proxy...');
|
||||
await proxy.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// HTTPS termination example with NFTables
|
||||
async function httpsTerminationExample() {
|
||||
console.log('Starting HTTPS termination with NFTables example...');
|
||||
|
||||
// Create a SmartProxy instance with an HTTPS termination route using NFTables
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesTerminateRoute('secure.example.com', {
|
||||
host: 'localhost',
|
||||
port: 8443
|
||||
}, {
|
||||
ports: 443,
|
||||
certificate: 'auto', // Automatic certificate provisioning
|
||||
tableName: 'smartproxy_https'
|
||||
})
|
||||
],
|
||||
enableDetailedLogging: true
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('HTTPS termination proxy started. Press Ctrl+C to stop.');
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Stopping proxy...');
|
||||
await proxy.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Complete HTTPS server with HTTP redirects using NFTables
|
||||
async function completeHttpsServerExample() {
|
||||
console.log('Starting complete HTTPS server with NFTables example...');
|
||||
|
||||
// Create a SmartProxy instance with a complete HTTPS server
|
||||
const proxy = new SmartProxy({
|
||||
routes: createCompleteNfTablesHttpsServer('complete.example.com', {
|
||||
host: 'localhost',
|
||||
port: 8443
|
||||
}, {
|
||||
certificate: 'auto',
|
||||
tableName: 'smartproxy_complete'
|
||||
}),
|
||||
enableDetailedLogging: true
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('Complete HTTPS server started. Press Ctrl+C to stop.');
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Stopping proxy...');
|
||||
await proxy.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Load balancing example with NFTables
|
||||
async function loadBalancingExample() {
|
||||
console.log('Starting load balancing with NFTables example...');
|
||||
|
||||
// Create a SmartProxy instance with a load balancing configuration
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesRoute('lb.example.com', {
|
||||
// NFTables will automatically distribute connections to these hosts
|
||||
host: 'backend1.example.com',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: 80,
|
||||
tableName: 'smartproxy_lb'
|
||||
})
|
||||
],
|
||||
enableDetailedLogging: true
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('Load balancing proxy started. Press Ctrl+C to stop.');
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Stopping proxy...');
|
||||
await proxy.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Advanced example with QoS and security settings
|
||||
async function advancedExample() {
|
||||
console.log('Starting advanced NFTables example with QoS and security...');
|
||||
|
||||
// Create a SmartProxy instance with advanced settings
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesRoute('advanced.example.com', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: 80,
|
||||
protocol: 'tcp',
|
||||
preserveSourceIP: true,
|
||||
maxRate: '10mbps', // QoS rate limiting
|
||||
priority: 2, // QoS priority (1-10, lower is higher priority)
|
||||
ipAllowList: ['192.168.1.0/24'], // Only allow this subnet
|
||||
ipBlockList: ['192.168.1.100'], // Block this specific IP
|
||||
useIPSets: true, // Use IP sets for more efficient rule processing
|
||||
useAdvancedNAT: true, // Use connection tracking for stateful NAT
|
||||
tableName: 'smartproxy_advanced'
|
||||
})
|
||||
],
|
||||
enableDetailedLogging: true
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('Advanced NFTables proxy started. Press Ctrl+C to stop.');
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Stopping proxy...');
|
||||
await proxy.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Run one of the examples based on the command line argument
|
||||
async function main() {
|
||||
const example = process.argv[2] || 'simple';
|
||||
|
||||
switch (example) {
|
||||
case 'simple':
|
||||
await simpleForwardingExample();
|
||||
break;
|
||||
case 'https':
|
||||
await httpsTerminationExample();
|
||||
break;
|
||||
case 'complete':
|
||||
await completeHttpsServerExample();
|
||||
break;
|
||||
case 'lb':
|
||||
await loadBalancingExample();
|
||||
break;
|
||||
case 'advanced':
|
||||
await advancedExample();
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown example:', example);
|
||||
console.log('Available examples: simple, https, complete, lb, advanced');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if running as root/sudo
|
||||
if (process.getuid && process.getuid() !== 0) {
|
||||
console.error('This example requires root privileges to modify nftables rules.');
|
||||
console.log('Please run with sudo: sudo tsx examples/nftables-integration.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error running example:', err);
|
||||
process.exit(1);
|
||||
});
|
@ -15,7 +15,7 @@
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.4.1",
|
||||
"@git.zone/tsbuild": "^2.5.0",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.77",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
|
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -52,8 +52,8 @@ importers:
|
||||
version: 8.18.2
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@git.zone/tsrun':
|
||||
specifier: ^1.2.44
|
||||
version: 1.3.3
|
||||
@ -680,8 +680,8 @@ packages:
|
||||
'@esm-bundle/chai@4.3.4-fix.0':
|
||||
resolution: {integrity: sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==}
|
||||
|
||||
'@git.zone/tsbuild@2.4.1':
|
||||
resolution: {integrity: sha512-JfigTr1egTChGU49CZfl6LWtamZIhKDqHQfwa2XWPcMwX1XGT6retTdpbcs+MDX5vadFfkVSSglgxVsZH63QVw==}
|
||||
'@git.zone/tsbuild@2.5.0':
|
||||
resolution: {integrity: sha512-4IL81yMtOdyA9hp/OLpo8t1svj/hjQhlTOWy5Y0S147GXKoGj2lD6/HZaxJ98nzlf/uQ1utQAcRb31KaC6misw==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsbundle@2.2.5':
|
||||
@ -5963,7 +5963,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 4.3.20
|
||||
|
||||
'@git.zone/tsbuild@2.4.1':
|
||||
'@git.zone/tsbuild@2.5.0':
|
||||
dependencies:
|
||||
'@git.zone/tspublish': 1.9.1
|
||||
'@push.rocks/early': 4.0.4
|
||||
|
768
readme.plan.md
768
readme.plan.md
@ -1,186 +1,654 @@
|
||||
# SmartProxy Interface Consolidation Plan
|
||||
# NFTables-SmartProxy Integration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a plan to consolidate duplicate and inconsistent interfaces in the SmartProxy codebase, specifically the `IRouteSecurity` interface which is defined twice with different properties. This inconsistency caused issues with security checks for port forwarding. The goal is to unify these interfaces, use consistent property naming, and improve code maintainability.
|
||||
This document outlines a comprehensive plan to integrate the existing NFTables functionality with the SmartProxy core to provide advanced network-level routing capabilities. The NFTables proxy already exists in the codebase but is not fully integrated with the SmartProxy routing system. This integration will allow SmartProxy to leverage the power of Linux's NFTables firewall system for high-performance port forwarding, load balancing, and security filtering.
|
||||
|
||||
## Problem Description (RESOLVED)
|
||||
## Current State
|
||||
|
||||
We had two separate `IRouteSecurity` interfaces defined in `ts/proxies/smart-proxy/models/route-types.ts` which have now been consolidated into a single interface:
|
||||
1. **NFTablesProxy**: A standalone implementation exists in `ts/proxies/nftables-proxy/` with its own configuration and API.
|
||||
2. **SmartProxy**: The main routing system with route-based configuration.
|
||||
3. **No Integration**: Currently, these systems operate independently with no shared configuration or coordination.
|
||||
|
||||
1. **First definition** (previous lines 116-122) - Used in IRouteAction:
|
||||
## Goals
|
||||
|
||||
1. Create a unified configuration system where SmartProxy routes can specify NFTables-based forwarding.
|
||||
2. Allow SmartProxy to dynamically provision and manage NFTables rules based on route configuration.
|
||||
3. Support advanced filtering and security rules through NFTables for better performance.
|
||||
4. Ensure backward compatibility with existing setups.
|
||||
5. Provide metrics integration between the systems.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Route Configuration Schema Extension
|
||||
|
||||
1. **Extend Route Configuration Schema**:
|
||||
- Add new `forwardingEngine` option to IRouteAction to specify the forwarding implementation.
|
||||
- Support values: 'node' (current NodeJS implementation) and 'nftables' (Linux NFTables).
|
||||
- Add NFTables-specific configuration options to IRouteAction.
|
||||
|
||||
2. **Update Type Definitions**:
|
||||
```typescript
|
||||
export interface IRouteSecurity {
|
||||
allowedIps?: string[];
|
||||
blockedIps?: string[];
|
||||
maxConnections?: number;
|
||||
authentication?: IRouteAuthentication;
|
||||
// In route-types.ts
|
||||
export interface IRouteAction {
|
||||
type: 'forward' | 'redirect' | 'block';
|
||||
target?: IRouteTarget;
|
||||
security?: IRouteSecurity;
|
||||
options?: IRouteOptions;
|
||||
tls?: IRouteTlsOptions;
|
||||
forwardingEngine?: 'node' | 'nftables'; // New field
|
||||
nftables?: INfTablesOptions; // New field
|
||||
}
|
||||
|
||||
export interface INfTablesOptions {
|
||||
preserveSourceIP?: boolean;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
maxRate?: string; // QoS rate limiting
|
||||
priority?: number; // QoS priority
|
||||
tableName?: string; // Optional custom table name
|
||||
useIPSets?: boolean; // Use IP sets for performance
|
||||
useAdvancedNAT?: boolean; // Use connection tracking
|
||||
}
|
||||
```
|
||||
|
||||
2. **Second definition** (previous lines 253-272) - Used directly in IRouteConfig:
|
||||
### Phase 2: NFTablesManager Implementation
|
||||
|
||||
1. **Create NFTablesManager Class**:
|
||||
- Create a new class to manage NFTables rules based on SmartProxy routes.
|
||||
- Add methods to create, update, and remove NFTables rules.
|
||||
- Design a rule naming scheme to track which rules correspond to which routes.
|
||||
|
||||
2. **Implementation**:
|
||||
```typescript
|
||||
export interface IRouteSecurity {
|
||||
rateLimit?: IRouteRateLimit;
|
||||
basicAuth?: {...};
|
||||
jwtAuth?: {...};
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
// In ts/proxies/smart-proxy/nftables-manager.ts
|
||||
export class NFTablesManager {
|
||||
private rulesMap: Map<string, NfTablesProxy> = new Map();
|
||||
|
||||
constructor(private options: ISmartProxyOptions) {}
|
||||
|
||||
/**
|
||||
* Provision NFTables rules for a route
|
||||
*/
|
||||
public async provisionRoute(route: IRouteConfig): Promise<boolean> {
|
||||
// Generate a unique ID for this route
|
||||
const routeId = this.generateRouteId(route);
|
||||
|
||||
// Skip if route doesn't use NFTables
|
||||
if (route.action.forwardingEngine !== 'nftables') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create NFTables options from route configuration
|
||||
const nftOptions = this.createNfTablesOptions(route);
|
||||
|
||||
// Create and start an NFTablesProxy instance
|
||||
const proxy = new NfTablesProxy(nftOptions);
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
this.rulesMap.set(routeId, proxy);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to provision NFTables rules for route ${route.name}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove NFTables rules for a route
|
||||
*/
|
||||
public async deprovisionRoute(route: IRouteConfig): Promise<boolean> {
|
||||
const routeId = this.generateRouteId(route);
|
||||
|
||||
const proxy = this.rulesMap.get(routeId);
|
||||
if (!proxy) {
|
||||
return true; // Nothing to remove
|
||||
}
|
||||
|
||||
try {
|
||||
await proxy.stop();
|
||||
this.rulesMap.delete(routeId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to deprovision NFTables rules for route ${route.name}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NFTables rules when route changes
|
||||
*/
|
||||
public async updateRoute(oldRoute: IRouteConfig, newRoute: IRouteConfig): Promise<boolean> {
|
||||
// Remove old rules and add new ones
|
||||
await this.deprovisionRoute(oldRoute);
|
||||
return this.provisionRoute(newRoute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a route
|
||||
*/
|
||||
private generateRouteId(route: IRouteConfig): string {
|
||||
// Generate a unique ID based on route properties
|
||||
return `${route.name || 'unnamed'}-${JSON.stringify(route.match)}-${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create NFTablesProxy options from a route configuration
|
||||
*/
|
||||
private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions {
|
||||
const { action } = route;
|
||||
|
||||
// Ensure we have a target
|
||||
if (!action.target) {
|
||||
throw new Error('Route must have a target to use NFTables forwarding');
|
||||
}
|
||||
|
||||
// Convert port specifications
|
||||
const fromPorts = this.expandPortRange(route.match.ports);
|
||||
|
||||
// Determine target port
|
||||
let toPorts;
|
||||
if (action.target.port === 'preserve') {
|
||||
// 'preserve' means use the same ports as the source
|
||||
toPorts = fromPorts;
|
||||
} else if (typeof action.target.port === 'function') {
|
||||
// For function-based ports, we can't determine at setup time
|
||||
// Use the "preserve" approach and let NFTables handle it
|
||||
toPorts = fromPorts;
|
||||
} else {
|
||||
toPorts = action.target.port;
|
||||
}
|
||||
|
||||
// Create options
|
||||
const options: NfTableProxyOptions = {
|
||||
fromPort: fromPorts,
|
||||
toPort: toPorts,
|
||||
toHost: typeof action.target.host === 'function'
|
||||
? 'localhost' // Can't determine at setup time, use localhost
|
||||
: (Array.isArray(action.target.host)
|
||||
? action.target.host[0] // Use first host for now
|
||||
: action.target.host),
|
||||
protocol: action.nftables?.protocol || 'tcp',
|
||||
preserveSourceIP: action.nftables?.preserveSourceIP,
|
||||
useIPSets: action.nftables?.useIPSets !== false,
|
||||
useAdvancedNAT: action.nftables?.useAdvancedNAT,
|
||||
enableLogging: this.options.enableDetailedLogging,
|
||||
deleteOnExit: true,
|
||||
tableName: action.nftables?.tableName || 'smartproxy'
|
||||
};
|
||||
|
||||
// Add security-related options
|
||||
if (action.security?.ipAllowList?.length) {
|
||||
options.allowedSourceIPs = action.security.ipAllowList;
|
||||
}
|
||||
|
||||
if (action.security?.ipBlockList?.length) {
|
||||
options.bannedSourceIPs = action.security.ipBlockList;
|
||||
}
|
||||
|
||||
// Add QoS options
|
||||
if (action.nftables?.maxRate || action.nftables?.priority) {
|
||||
options.qos = {
|
||||
enabled: true,
|
||||
maxRate: action.nftables.maxRate,
|
||||
priority: action.nftables.priority
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand port range specifications
|
||||
*/
|
||||
private expandPortRange(ports: TPortRange): number | PortRange | Array<number | PortRange> {
|
||||
// Use RouteManager's expandPortRange to convert to actual port numbers
|
||||
const routeManager = new RouteManager(this.options);
|
||||
|
||||
// Process different port specifications
|
||||
if (typeof ports === 'number') {
|
||||
return ports;
|
||||
} else if (Array.isArray(ports)) {
|
||||
const result: Array<number | PortRange> = [];
|
||||
|
||||
for (const item of ports) {
|
||||
if (typeof item === 'number') {
|
||||
result.push(item);
|
||||
} else if ('from' in item && 'to' in item) {
|
||||
result.push({ from: item.from, to: item.to });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} else if ('from' in ports && 'to' in ports) {
|
||||
return { from: ports.from, to: ports.to };
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all managed rules
|
||||
*/
|
||||
public async getStatus(): Promise<Record<string, NfTablesStatus>> {
|
||||
const result: Record<string, NfTablesStatus> = {};
|
||||
|
||||
for (const [routeId, proxy] of this.rulesMap.entries()) {
|
||||
result[routeId] = await proxy.getStatus();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all NFTables rules
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
// Stop all NFTables proxies
|
||||
const stopPromises = Array.from(this.rulesMap.values()).map(proxy => proxy.stop());
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
this.rulesMap.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This duplication with inconsistent naming (`allowedIps` vs `ipAllowList` and `blockedIps` vs `ipBlockList`) caused routing issues when IP security checks were used, particularly with port range configurations.
|
||||
### Phase 3: SmartProxy Integration
|
||||
|
||||
## Implementation Plan (COMPLETED)
|
||||
1. **Extend SmartProxy Class**:
|
||||
- Add NFTablesManager as a property of SmartProxy.
|
||||
- Hook into route configuration to provision NFTables rules.
|
||||
- Add methods to manage NFTables functionality.
|
||||
|
||||
### Phase 1: Interface Consolidation ✅
|
||||
2. **Implementation**:
|
||||
```typescript
|
||||
// In ts/proxies/smart-proxy/smart-proxy.ts
|
||||
import { NFTablesManager } from './nftables-manager.js';
|
||||
|
||||
1. **Create a unified interface definition:** ✅
|
||||
- Created one comprehensive `IRouteSecurity` interface that includes all properties
|
||||
- Standardized on `ipAllowList` and `ipBlockList` property names
|
||||
- Added proper documentation for each property
|
||||
- Removed the duplicate interface definition
|
||||
export class SmartProxy {
|
||||
// Existing properties
|
||||
private nftablesManager: NFTablesManager;
|
||||
|
||||
constructor(options: ISmartProxyOptions) {
|
||||
// Existing initialization
|
||||
|
||||
// Initialize NFTablesManager
|
||||
this.nftablesManager = new NFTablesManager(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the SmartProxy server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Existing initialization
|
||||
|
||||
// If we have routes, provision NFTables rules for them
|
||||
for (const route of this.settings.routes) {
|
||||
if (route.action.forwardingEngine === 'nftables') {
|
||||
await this.nftablesManager.provisionRoute(route);
|
||||
}
|
||||
}
|
||||
|
||||
// Rest of existing start method
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the SmartProxy server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
// Stop NFTablesManager first
|
||||
await this.nftablesManager.stop();
|
||||
|
||||
// Rest of existing stop method
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routes
|
||||
*/
|
||||
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
||||
// Get existing routes that use NFTables
|
||||
const oldNfTablesRoutes = this.settings.routes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
// Get new routes that use NFTables
|
||||
const newNfTablesRoutes = routes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
// Find routes to remove, update, or add
|
||||
for (const oldRoute of oldNfTablesRoutes) {
|
||||
const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
|
||||
|
||||
if (!newRoute) {
|
||||
// Route was removed
|
||||
await this.nftablesManager.deprovisionRoute(oldRoute);
|
||||
} else {
|
||||
// Route was updated
|
||||
await this.nftablesManager.updateRoute(oldRoute, newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Find new routes to add
|
||||
for (const newRoute of newNfTablesRoutes) {
|
||||
const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
|
||||
|
||||
if (!oldRoute) {
|
||||
// New route
|
||||
await this.nftablesManager.provisionRoute(newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Update settings with the new routes
|
||||
this.settings.routes = routes;
|
||||
|
||||
// Update route manager with new routes
|
||||
this.routeManager.updateRoutes(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NFTables status
|
||||
*/
|
||||
public async getNfTablesStatus(): Promise<Record<string, NfTablesStatus>> {
|
||||
return this.nftablesManager.getStatus();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Routing System Integration
|
||||
|
||||
1. **Extend the Route-Connection-Handler**:
|
||||
- Modify to check if a route uses NFTables.
|
||||
- Skip Node.js-based connection handling for NFTables routes.
|
||||
|
||||
2. **Implementation**:
|
||||
```typescript
|
||||
// In ts/proxies/smart-proxy/route-connection-handler.ts
|
||||
export class RouteConnectionHandler {
|
||||
// Existing methods
|
||||
|
||||
/**
|
||||
* Route the connection based on match criteria
|
||||
*/
|
||||
private routeConnection(
|
||||
socket: plugins.net.Socket,
|
||||
record: IConnectionRecord,
|
||||
serverName: string,
|
||||
initialChunk?: Buffer
|
||||
): void {
|
||||
// Find matching route
|
||||
const routeMatch = this.routeManager.findMatchingRoute({
|
||||
port: record.localPort,
|
||||
domain: serverName,
|
||||
clientIp: record.remoteIP,
|
||||
path: undefined,
|
||||
tlsVersion: undefined
|
||||
});
|
||||
|
||||
if (!routeMatch) {
|
||||
// Existing code for no matching route
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeMatch.route;
|
||||
|
||||
// Check if this route uses NFTables for forwarding
|
||||
if (route.action.forwardingEngine === 'nftables') {
|
||||
// For NFTables routes, we don't need to do anything at the application level
|
||||
// The packet is forwarded at the kernel level
|
||||
|
||||
// Log the connection
|
||||
console.log(
|
||||
`[${record.id}] Connection forwarded by NFTables: ${record.remoteIP} -> port ${record.localPort}`
|
||||
);
|
||||
|
||||
// Just close the socket in our application since it's handled at kernel level
|
||||
socket.end();
|
||||
this.connectionManager.initiateCleanupOnce(record, 'nftables_handled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing code for handling the route
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: CLI and Configuration Helpers
|
||||
|
||||
1. **Add Helper Functions**:
|
||||
- Create helper functions for easy route creation with NFTables.
|
||||
- Update the route-helpers.ts utility file.
|
||||
|
||||
2. **Implementation**:
|
||||
```typescript
|
||||
// In ts/proxies/smart-proxy/utils/route-helpers.ts
|
||||
|
||||
2. **Update references to use the unified interface:** ✅
|
||||
- Updated all code that references the old interface properties
|
||||
- Updated all configurations to use the new property names
|
||||
- Ensured implementation in `route-manager.ts` uses the correct property names
|
||||
/**
|
||||
* Create an NFTables-based route
|
||||
*/
|
||||
export function createNfTablesRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
allowedIps?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
useTls?: boolean;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Determine if this is a name or domain
|
||||
let name: string;
|
||||
let domains: string | string[];
|
||||
|
||||
if (Array.isArray(nameOrDomains) || nameOrDomains.includes('.')) {
|
||||
domains = nameOrDomains;
|
||||
name = Array.isArray(nameOrDomains) ? nameOrDomains[0] : nameOrDomains;
|
||||
} else {
|
||||
name = nameOrDomains;
|
||||
domains = []; // No domains
|
||||
}
|
||||
|
||||
const route: IRouteConfig = {
|
||||
name,
|
||||
match: {
|
||||
domains,
|
||||
ports: options.ports || 80
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: target.host,
|
||||
port: target.port
|
||||
},
|
||||
forwardingEngine: 'nftables',
|
||||
nftables: {
|
||||
protocol: options.protocol || 'tcp',
|
||||
preserveSourceIP: options.preserveSourceIP,
|
||||
maxRate: options.maxRate,
|
||||
priority: options.priority
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add security if allowed IPs are specified
|
||||
if (options.allowedIps?.length) {
|
||||
route.action.security = {
|
||||
ipAllowList: options.allowedIps
|
||||
};
|
||||
}
|
||||
|
||||
// Add TLS options if needed
|
||||
if (options.useTls) {
|
||||
route.action.tls = {
|
||||
mode: 'passthrough'
|
||||
};
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NFTables-based TLS termination route
|
||||
*/
|
||||
export function createNfTablesTerminateRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
allowedIps?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
certificate?: string | { cert: string; key: string };
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
const route = createNfTablesRoute(
|
||||
nameOrDomains,
|
||||
target,
|
||||
{
|
||||
...options,
|
||||
ports: options.ports || 443,
|
||||
useTls: false
|
||||
}
|
||||
);
|
||||
|
||||
// Set TLS termination
|
||||
route.action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
};
|
||||
|
||||
return route;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Code and Documentation Updates ✅
|
||||
### Phase 6: Documentation and Testing
|
||||
|
||||
1. **Update type usages and documentation:** ✅
|
||||
- Updated all code that creates or uses security configurations
|
||||
- Updated documentation to reflect the new interface structure
|
||||
- Added examples of the correct property usage
|
||||
- Documented the changes in this plan
|
||||
1. **Update Documentation**:
|
||||
- Add NFTables integration documentation to README and API docs.
|
||||
- Document the implementation and use cases.
|
||||
|
||||
2. **Fix TypeScript errors:** ✅
|
||||
- Fixed TypeScript errors in http-request-handler.ts
|
||||
- Successfully built the project with `pnpm run build`
|
||||
2. **Test Cases**:
|
||||
- Create test cases for NFTables-based routing.
|
||||
- Test performance comparison with Node.js-based forwarding.
|
||||
- Test security features with IP allowlists/blocklists.
|
||||
|
||||
## Implementation Completed ✅
|
||||
|
||||
The interface consolidation has been successfully implemented with the following changes:
|
||||
|
||||
1. **Unified interface created:**
|
||||
```typescript
|
||||
// Consolidated interface definition
|
||||
export interface IRouteSecurity {
|
||||
// Access control lists
|
||||
ipAllowList?: string[]; // IP addresses that are allowed to connect
|
||||
ipBlockList?: string[]; // IP addresses that are blocked from connecting
|
||||
// In test/test.nftables-integration.ts
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as net from 'net';
|
||||
|
||||
// Test server and client utilities
|
||||
let testServer: net.Server;
|
||||
let smartProxy: SmartProxy;
|
||||
|
||||
const TEST_PORT = 4000;
|
||||
const PROXY_PORT = 5000;
|
||||
const TEST_DATA = 'Hello through NFTables!';
|
||||
|
||||
tap.test('setup NFTables integration test environment', async () => {
|
||||
// Create a test TCP server
|
||||
testServer = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
socket.write(`Server says: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Connection limits
|
||||
maxConnections?: number; // Maximum concurrent connections
|
||||
await new Promise<void>((resolve) => {
|
||||
testServer.listen(TEST_PORT, () => {
|
||||
console.log(`Test server listening on port ${TEST_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Authentication
|
||||
authentication?: IRouteAuthentication;
|
||||
// Create SmartProxy with NFTables route
|
||||
smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesRoute('test-nftables', {
|
||||
host: 'localhost',
|
||||
port: TEST_PORT
|
||||
}, {
|
||||
ports: PROXY_PORT,
|
||||
protocol: 'tcp'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Rate limiting
|
||||
rateLimit?: IRouteRateLimit;
|
||||
// Start the proxy
|
||||
await smartProxy.start();
|
||||
});
|
||||
|
||||
tap.test('should forward TCP connections through NFTables', async () => {
|
||||
// Connect to the proxy port
|
||||
const client = new net.Socket();
|
||||
|
||||
// Authentication methods
|
||||
basicAuth?: {
|
||||
enabled: boolean;
|
||||
users: Array<{ username: string; password: string }>;
|
||||
realm?: string;
|
||||
excludePaths?: string[];
|
||||
};
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
let responseData = '';
|
||||
|
||||
client.connect(PROXY_PORT, 'localhost', () => {
|
||||
client.write(TEST_DATA);
|
||||
});
|
||||
|
||||
client.on('data', (data) => {
|
||||
responseData += data.toString();
|
||||
client.end();
|
||||
});
|
||||
|
||||
client.on('end', () => {
|
||||
resolve(responseData);
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
jwtAuth?: {
|
||||
enabled: boolean;
|
||||
secret: string;
|
||||
algorithm?: string;
|
||||
issuer?: string;
|
||||
audience?: string;
|
||||
expiresIn?: number;
|
||||
excludePaths?: string[];
|
||||
};
|
||||
}
|
||||
expect(response).toEqual(`Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.test('cleanup NFTables integration test environment', async () => {
|
||||
// Stop the proxy and test server
|
||||
await smartProxy.stop();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
```
|
||||
|
||||
2. **Updated isClientIpAllowed method:**
|
||||
```typescript
|
||||
private isClientIpAllowed(route: IRouteConfig, clientIp: string): boolean {
|
||||
const security = route.action.security;
|
||||
|
||||
if (!security) {
|
||||
return true; // No security settings means allowed
|
||||
}
|
||||
|
||||
// Check blocked IPs first
|
||||
if (security.ipBlockList && security.ipBlockList.length > 0) {
|
||||
for (const pattern of security.ipBlockList) {
|
||||
if (this.matchIpPattern(pattern, clientIp)) {
|
||||
return false; // IP is blocked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are allowed IPs, check them
|
||||
if (security.ipAllowList && security.ipAllowList.length > 0) {
|
||||
for (const pattern of security.ipAllowList) {
|
||||
if (this.matchIpPattern(pattern, clientIp)) {
|
||||
return true; // IP is allowed
|
||||
}
|
||||
}
|
||||
return false; // IP not in allowed list
|
||||
}
|
||||
|
||||
// No allowed IPs specified, so IP is allowed
|
||||
return true;
|
||||
}
|
||||
```
|
||||
## Expected Benefits
|
||||
|
||||
3. **Fixed port preservation logic:**
|
||||
```typescript
|
||||
// In base-handler.ts
|
||||
protected resolvePort(
|
||||
port: number | 'preserve' | ((ctx: any) => number),
|
||||
incomingPort: number = 80
|
||||
): number {
|
||||
if (typeof port === 'function') {
|
||||
try {
|
||||
// Create a minimal context for the function that includes the incoming port
|
||||
const ctx = { port: incomingPort };
|
||||
return port(ctx);
|
||||
} catch (err) {
|
||||
console.error('Error resolving port function:', err);
|
||||
return incomingPort; // Fall back to incoming port
|
||||
}
|
||||
} else if (port === 'preserve') {
|
||||
return incomingPort; // Use the actual incoming port for 'preserve'
|
||||
} else {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
```
|
||||
1. **Performance**: NFTables operates at the kernel level, offering much higher performance than Node.js-based routing.
|
||||
2. **Scalability**: Handle more connections with less CPU and memory usage.
|
||||
3. **Security**: Leverage kernel-level security features for better protection.
|
||||
4. **Integration**: Unified configuration model between application and network layers.
|
||||
5. **Advanced Features**: Support for QoS, rate limiting, and other advanced networking features.
|
||||
|
||||
4. **Fixed TypeScript error in http-request-handler.ts:**
|
||||
```typescript
|
||||
// Safely check for host property existence
|
||||
if (options.headers && 'host' in options.headers) {
|
||||
// Only apply if host header rewrite is enabled or not explicitly disabled
|
||||
const shouldRewriteHost = route?.action.options?.rewriteHostHeader !== false;
|
||||
if (shouldRewriteHost) {
|
||||
// Safely cast to OutgoingHttpHeaders to access host property
|
||||
(options.headers as plugins.http.OutgoingHttpHeaders).host = `${destination.host}:${destination.port}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
## Implementation Notes
|
||||
|
||||
## Achieved Benefits ✅
|
||||
- This integration requires root/sudo access to configure NFTables rules.
|
||||
- Consider adding a capability check to gracefully fall back to Node.js routing if NFTables is not available.
|
||||
- The NFTables integration should be optional and SmartProxy should continue to work without it.
|
||||
- The integration provides a path for future extensions to other kernel-level networking features.
|
||||
|
||||
- **Improved Consistency**: Single, unified interface with consistent property naming
|
||||
- **Better Type Safety**: Eliminated confusing duplicate interface definitions
|
||||
- **Reduced Errors**: Prevented misunderstandings about which property names to use
|
||||
- **Forward Compatibility**: Clearer path for future security enhancements
|
||||
- **Better Developer Experience**: Simplified interface with comprehensive documentation
|
||||
- **Fixed Issues**: Port preservation with port ranges now works correctly with security checks
|
||||
## Timeline
|
||||
|
||||
## Verification ✅
|
||||
- Phase 1 (Route Configuration Schema): 1-2 days
|
||||
- Phase 2 (NFTablesManager): 2-3 days
|
||||
- Phase 3 (SmartProxy Integration): 1-2 days
|
||||
- Phase 4 (Routing System Integration): 1 day
|
||||
- Phase 5 (CLI and Helpers): 1 day
|
||||
- Phase 6 (Documentation and Testing): 2 days
|
||||
|
||||
- The project builds successfully with `pnpm run build`
|
||||
- The unified interface works properly with all type checking
|
||||
- The port range forwarding with `port: 'preserve'` now works correctly with IP security rules
|
||||
- The security checks consistently use the standardized property names throughout the codebase
|
||||
**Total Estimated Time: 8-11 days**
|
34
summary-nftables-naming-consolidation.md
Normal file
34
summary-nftables-naming-consolidation.md
Normal file
@ -0,0 +1,34 @@
|
||||
# NFTables Naming Consolidation Summary
|
||||
|
||||
This document summarizes the changes made to consolidate the naming convention for IP allow/block lists in the NFTables integration.
|
||||
|
||||
## Changes Made
|
||||
|
||||
1. **Updated NFTablesProxy interface** (`ts/proxies/nftables-proxy/models/interfaces.ts`):
|
||||
- Changed `allowedSourceIPs` to `ipAllowList`
|
||||
- Changed `bannedSourceIPs` to `ipBlockList`
|
||||
|
||||
2. **Updated NFTablesProxy implementation** (`ts/proxies/nftables-proxy/nftables-proxy.ts`):
|
||||
- Updated all references from `allowedSourceIPs` to `ipAllowList`
|
||||
- Updated all references from `bannedSourceIPs` to `ipBlockList`
|
||||
|
||||
3. **Updated NFTablesManager** (`ts/proxies/smart-proxy/nftables-manager.ts`):
|
||||
- Changed mapping from `allowedSourceIPs` to `ipAllowList`
|
||||
- Changed mapping from `bannedSourceIPs` to `ipBlockList`
|
||||
|
||||
## Files Already Using Consistent Naming
|
||||
|
||||
The following files already used the consistent naming convention `ipAllowList` and `ipBlockList`:
|
||||
|
||||
1. **Route helpers** (`ts/proxies/smart-proxy/utils/route-helpers.ts`)
|
||||
2. **Integration test** (`test/test.nftables-integration.ts`)
|
||||
3. **NFTables example** (`examples/nftables-integration.ts`)
|
||||
4. **Route types** (`ts/proxies/smart-proxy/models/route-types.ts`)
|
||||
|
||||
## Result
|
||||
|
||||
The naming is now consistent throughout the codebase:
|
||||
- `ipAllowList` is used for lists of allowed IP addresses
|
||||
- `ipBlockList` is used for lists of blocked IP addresses
|
||||
|
||||
This matches the naming convention already established in SmartProxy's core routing system.
|
@ -28,7 +28,7 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
port: 3000
|
||||
},
|
||||
security: {
|
||||
allowedIps: ['*'] // Allow all
|
||||
ipAllowList: ['*'] // Allow all
|
||||
},
|
||||
name: 'Basic HTTP Route'
|
||||
});
|
||||
@ -45,7 +45,7 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
port: 443
|
||||
},
|
||||
security: {
|
||||
allowedIps: ['*'] // Allow all
|
||||
ipAllowList: ['*'] // Allow all
|
||||
},
|
||||
name: 'HTTPS Passthrough Route'
|
||||
});
|
||||
@ -67,7 +67,7 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
'X-Forwarded-Proto': 'https'
|
||||
},
|
||||
security: {
|
||||
allowedIps: ['*'] // Allow all
|
||||
ipAllowList: ['*'] // Allow all
|
||||
},
|
||||
name: 'HTTPS Termination to HTTP Backend'
|
||||
});
|
||||
@ -94,7 +94,7 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
'X-Original-Host': '{domain}'
|
||||
},
|
||||
security: {
|
||||
allowedIps: ['10.0.0.0/24', '192.168.1.0/24'],
|
||||
ipAllowList: ['10.0.0.0/24', '192.168.1.0/24'],
|
||||
maxConnections: 1000
|
||||
},
|
||||
name: 'Load Balanced HTTPS Route'
|
||||
@ -103,7 +103,7 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
expect(loadBalancerRoute).toBeTruthy();
|
||||
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
expect(Array.isArray(loadBalancerRoute.action.target?.host)).toBeTrue();
|
||||
expect(loadBalancerRoute.action.security?.allowedIps?.length).toEqual(2);
|
||||
expect(loadBalancerRoute.action.security?.ipAllowList?.length).toEqual(2);
|
||||
|
||||
// Example 5: Block specific IPs
|
||||
const blockRoute = createBlockRoute({
|
||||
|
94
test/test.nftables-integration.simple.ts
Normal file
94
test/test.nftables-integration.simple.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Check if we have root privileges to run NFTables tests
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
// Check if we're running as root
|
||||
const { stdout } = await exec('id -u');
|
||||
return stdout.trim() === '0';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tests should run
|
||||
const isRoot = await checkRootPrivileges();
|
||||
|
||||
if (!isRoot) {
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
console.log('NFTables tests require root privileges');
|
||||
console.log('Skipping NFTables integration tests');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
tap.test('NFTables integration tests', async () => {
|
||||
|
||||
console.log('Running NFTables tests with root privileges');
|
||||
|
||||
// Create test routes
|
||||
const routes = [
|
||||
createNfTablesRoute('tcp-forward', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: 9080,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
createNfTablesRoute('udp-forward', {
|
||||
host: 'localhost',
|
||||
port: 5353
|
||||
}, {
|
||||
ports: 5354,
|
||||
protocol: 'udp'
|
||||
}),
|
||||
|
||||
createNfTablesRoute('port-range', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: { from: 9000, to: 9100 },
|
||||
protocol: 'tcp'
|
||||
})
|
||||
];
|
||||
|
||||
const smartProxy = new SmartProxy({
|
||||
enableDetailedLogging: true,
|
||||
routes
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await smartProxy.start();
|
||||
console.log('SmartProxy started with NFTables routes');
|
||||
|
||||
// Get NFTables status
|
||||
const status = await smartProxy.getNfTablesStatus();
|
||||
console.log('NFTables status:', JSON.stringify(status, null, 2));
|
||||
|
||||
// Verify all routes are provisioned
|
||||
expect(Object.keys(status).length).toEqual(routes.length);
|
||||
|
||||
for (const routeStatus of Object.values(status)) {
|
||||
expect(routeStatus.active).toBeTrue();
|
||||
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Stop the proxy
|
||||
await smartProxy.stop();
|
||||
console.log('SmartProxy stopped');
|
||||
|
||||
// Verify all rules are cleaned up
|
||||
const finalStatus = await smartProxy.getNfTablesStatus();
|
||||
expect(Object.keys(finalStatus).length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
351
test/test.nftables-integration.ts
Normal file
351
test/test.nftables-integration.ts
Normal file
@ -0,0 +1,351 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Check if we have root privileges
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await exec('id -u');
|
||||
return stdout.trim() === '0';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tests should run
|
||||
const runTests = await checkRootPrivileges();
|
||||
|
||||
if (!runTests) {
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
console.log('NFTables tests require root privileges');
|
||||
console.log('Skipping NFTables integration tests');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
// Exit without running any tests
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Test server and client utilities
|
||||
let testTcpServer: net.Server;
|
||||
let testHttpServer: http.Server;
|
||||
let testHttpsServer: https.Server;
|
||||
let smartProxy: SmartProxy;
|
||||
|
||||
const TEST_TCP_PORT = 4000;
|
||||
const TEST_HTTP_PORT = 4001;
|
||||
const TEST_HTTPS_PORT = 4002;
|
||||
const PROXY_TCP_PORT = 5000;
|
||||
const PROXY_HTTP_PORT = 5001;
|
||||
const PROXY_HTTPS_PORT = 5002;
|
||||
const TEST_DATA = 'Hello through NFTables!';
|
||||
|
||||
// Helper to create test certificates
|
||||
async function createTestCertificates() {
|
||||
try {
|
||||
// Import the certificate helper
|
||||
const certsModule = await import('./helpers/certificates.js');
|
||||
const certificates = certsModule.loadTestCertificates();
|
||||
return {
|
||||
cert: certificates.publicKey,
|
||||
key: certificates.privateKey
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load test certificates:', err);
|
||||
// Use dummy certificates for testing
|
||||
return {
|
||||
cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'),
|
||||
key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('setup NFTables integration test environment', async () => {
|
||||
console.log('Running NFTables integration tests with root privileges');
|
||||
|
||||
// Create a basic TCP test server
|
||||
testTcpServer = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
socket.write(`Server says: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testTcpServer.listen(TEST_TCP_PORT, () => {
|
||||
console.log(`TCP test server listening on port ${TEST_TCP_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create an HTTP test server
|
||||
testHttpServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(`HTTP Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpServer.listen(TEST_HTTP_PORT, () => {
|
||||
console.log(`HTTP test server listening on port ${TEST_HTTP_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create an HTTPS test server
|
||||
const certs = await createTestCertificates();
|
||||
testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(`HTTPS Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpsServer.listen(TEST_HTTPS_PORT, () => {
|
||||
console.log(`HTTPS test server listening on port ${TEST_HTTPS_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create SmartProxy with various NFTables routes
|
||||
smartProxy = new SmartProxy({
|
||||
enableDetailedLogging: true,
|
||||
routes: [
|
||||
// TCP forwarding route
|
||||
createNfTablesRoute('tcp-nftables', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: PROXY_TCP_PORT,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
// HTTP forwarding route
|
||||
createNfTablesRoute('http-nftables', {
|
||||
host: 'localhost',
|
||||
port: TEST_HTTP_PORT
|
||||
}, {
|
||||
ports: PROXY_HTTP_PORT,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
// HTTPS termination route
|
||||
createNfTablesTerminateRoute('https-nftables.example.com', {
|
||||
host: 'localhost',
|
||||
port: TEST_HTTPS_PORT
|
||||
}, {
|
||||
ports: PROXY_HTTPS_PORT,
|
||||
protocol: 'tcp',
|
||||
certificate: certs
|
||||
}),
|
||||
|
||||
// Route with IP allow list
|
||||
createNfTablesRoute('secure-tcp', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: 5003,
|
||||
protocol: 'tcp',
|
||||
ipAllowList: ['127.0.0.1', '::1']
|
||||
}),
|
||||
|
||||
// Route with QoS settings
|
||||
createNfTablesRoute('qos-tcp', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: 5004,
|
||||
protocol: 'tcp',
|
||||
maxRate: '10mbps',
|
||||
priority: 1
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
console.log('SmartProxy created, now starting...');
|
||||
|
||||
// Start the proxy
|
||||
try {
|
||||
await smartProxy.start();
|
||||
console.log('SmartProxy started successfully');
|
||||
|
||||
// Verify proxy is listening on expected ports
|
||||
const listeningPorts = smartProxy.getListeningPorts();
|
||||
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to start SmartProxy:', err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should forward TCP connections through NFTables', async () => {
|
||||
console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`);
|
||||
|
||||
// First verify our test server is running
|
||||
try {
|
||||
const testClient = new net.Socket();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
testClient.connect(TEST_TCP_PORT, 'localhost', () => {
|
||||
console.log(`Test server on port ${TEST_TCP_PORT} is accessible`);
|
||||
testClient.end();
|
||||
resolve();
|
||||
});
|
||||
testClient.on('error', reject);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Test server on port ${TEST_TCP_PORT} is not accessible: ${err}`);
|
||||
}
|
||||
|
||||
// Connect to the proxy port
|
||||
const client = new net.Socket();
|
||||
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
let responseData = '';
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
reject(new Error(`Connection timeout after 5 seconds to proxy port ${PROXY_TCP_PORT}`));
|
||||
}, 5000);
|
||||
|
||||
client.connect(PROXY_TCP_PORT, 'localhost', () => {
|
||||
console.log(`Connected to proxy port ${PROXY_TCP_PORT}, sending data...`);
|
||||
client.write(TEST_DATA);
|
||||
});
|
||||
|
||||
client.on('data', (data) => {
|
||||
console.log(`Received data from proxy: ${data.toString()}`);
|
||||
responseData += data.toString();
|
||||
client.end();
|
||||
});
|
||||
|
||||
client.on('end', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(responseData);
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`Connection error on proxy port ${PROXY_TCP_PORT}: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
expect(response).toEqual(`Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.test('should forward HTTP connections through NFTables', async () => {
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
http.get(`http://localhost:${PROXY_HTTP_PORT}`, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
resolve(data);
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
expect(response).toEqual(`HTTP Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.test('should handle HTTPS termination with NFTables', async () => {
|
||||
// Skip this test if running without proper certificates
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: PROXY_HTTPS_PORT,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false // For self-signed cert
|
||||
};
|
||||
|
||||
https.get(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
resolve(data);
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
expect(response).toEqual(`HTTPS Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.test('should respect IP allow lists in NFTables', async () => {
|
||||
// This test should pass since we're connecting from localhost
|
||||
const client = new net.Socket();
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
resolve(false);
|
||||
}, 2000);
|
||||
|
||||
client.connect(5003, 'localhost', () => {
|
||||
clearTimeout(timeout);
|
||||
client.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
client.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
expect(connected).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should get NFTables status', async () => {
|
||||
const status = await smartProxy.getNfTablesStatus();
|
||||
|
||||
// Check that we have status for our routes
|
||||
const statusKeys = Object.keys(status);
|
||||
expect(statusKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// Check status structure for one of the routes
|
||||
const firstStatus = status[statusKeys[0]];
|
||||
expect(firstStatus).toHaveProperty('active');
|
||||
expect(firstStatus).toHaveProperty('ruleCount');
|
||||
expect(firstStatus.ruleCount).toHaveProperty('total');
|
||||
expect(firstStatus.ruleCount).toHaveProperty('added');
|
||||
});
|
||||
|
||||
tap.test('cleanup NFTables integration test environment', async () => {
|
||||
// Stop the proxy and test servers
|
||||
await smartProxy.stop();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testTcpServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpsServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default tap.start();
|
208
test/test.nftables-manager.ts
Normal file
208
test/test.nftables-manager.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { NFTablesManager } from '../ts/proxies/smart-proxy/nftables-manager.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
import type { ISmartProxyOptions } from '../ts/proxies/smart-proxy/models/interfaces.js';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Check if we have root privileges
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await exec('id -u');
|
||||
return stdout.trim() === '0';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip tests if not root
|
||||
const isRoot = await checkRootPrivileges();
|
||||
if (!isRoot) {
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
console.log('NFTablesManager tests require root privileges');
|
||||
console.log('Skipping NFTablesManager tests');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests for the NFTablesManager class
|
||||
*/
|
||||
|
||||
// Sample route configurations for testing
|
||||
const sampleRoute: IRouteConfig = {
|
||||
name: 'test-nftables-route',
|
||||
match: {
|
||||
ports: 8080,
|
||||
domains: 'test.example.com'
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8000
|
||||
},
|
||||
forwardingEngine: 'nftables',
|
||||
nftables: {
|
||||
protocol: 'tcp',
|
||||
preserveSourceIP: true,
|
||||
useIPSets: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sample SmartProxy options
|
||||
const sampleOptions: ISmartProxyOptions = {
|
||||
routes: [sampleRoute],
|
||||
enableDetailedLogging: true
|
||||
};
|
||||
|
||||
// Instance of NFTablesManager for testing
|
||||
let manager: NFTablesManager;
|
||||
|
||||
// Skip these tests by default since they require root privileges to run NFTables commands
|
||||
// When running as root, change this to false
|
||||
const SKIP_TESTS = true;
|
||||
|
||||
tap.test('NFTablesManager setup test', async () => {
|
||||
if (SKIP_TESTS) {
|
||||
console.log('Test skipped - requires root privileges to run NFTables commands');
|
||||
expect(true).toEqual(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new instance of NFTablesManager
|
||||
manager = new NFTablesManager(sampleOptions);
|
||||
|
||||
// Verify the instance was created successfully
|
||||
expect(manager).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('NFTablesManager route provisioning test', async () => {
|
||||
if (SKIP_TESTS) {
|
||||
console.log('Test skipped - requires root privileges to run NFTables commands');
|
||||
expect(true).toEqual(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Provision the sample route
|
||||
const result = await manager.provisionRoute(sampleRoute);
|
||||
|
||||
// Verify the route was provisioned successfully
|
||||
expect(result).toEqual(true);
|
||||
|
||||
// Verify the route is listed as provisioned
|
||||
expect(manager.isRouteProvisioned(sampleRoute)).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('NFTablesManager status test', async () => {
|
||||
if (SKIP_TESTS) {
|
||||
console.log('Test skipped - requires root privileges to run NFTables commands');
|
||||
expect(true).toEqual(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the status of the managed rules
|
||||
const status = await manager.getStatus();
|
||||
|
||||
// Verify status includes our route
|
||||
const keys = Object.keys(status);
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
|
||||
// Check the status of the first rule
|
||||
const firstStatus = status[keys[0]];
|
||||
expect(firstStatus.active).toEqual(true);
|
||||
expect(firstStatus.ruleCount.added).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('NFTablesManager route updating test', async () => {
|
||||
if (SKIP_TESTS) {
|
||||
console.log('Test skipped - requires root privileges to run NFTables commands');
|
||||
expect(true).toEqual(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an updated version of the sample route
|
||||
const updatedRoute: IRouteConfig = {
|
||||
...sampleRoute,
|
||||
action: {
|
||||
...sampleRoute.action,
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9000 // Different port
|
||||
},
|
||||
nftables: {
|
||||
...sampleRoute.action.nftables,
|
||||
protocol: 'all' // Different protocol
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Update the route
|
||||
const result = await manager.updateRoute(sampleRoute, updatedRoute);
|
||||
|
||||
// Verify the route was updated successfully
|
||||
expect(result).toEqual(true);
|
||||
|
||||
// Verify the old route is no longer provisioned
|
||||
expect(manager.isRouteProvisioned(sampleRoute)).toEqual(false);
|
||||
|
||||
// Verify the new route is provisioned
|
||||
expect(manager.isRouteProvisioned(updatedRoute)).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('NFTablesManager route deprovisioning test', async () => {
|
||||
if (SKIP_TESTS) {
|
||||
console.log('Test skipped - requires root privileges to run NFTables commands');
|
||||
expect(true).toEqual(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an updated version of the sample route from the previous test
|
||||
const updatedRoute: IRouteConfig = {
|
||||
...sampleRoute,
|
||||
action: {
|
||||
...sampleRoute.action,
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9000 // Different port from original test
|
||||
},
|
||||
nftables: {
|
||||
...sampleRoute.action.nftables,
|
||||
protocol: 'all' // Different protocol from original test
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Deprovision the route
|
||||
const result = await manager.deprovisionRoute(updatedRoute);
|
||||
|
||||
// Verify the route was deprovisioned successfully
|
||||
expect(result).toEqual(true);
|
||||
|
||||
// Verify the route is no longer provisioned
|
||||
expect(manager.isRouteProvisioned(updatedRoute)).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('NFTablesManager cleanup test', async () => {
|
||||
if (SKIP_TESTS) {
|
||||
console.log('Test skipped - requires root privileges to run NFTables commands');
|
||||
expect(true).toEqual(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop all NFTables rules
|
||||
await manager.stop();
|
||||
|
||||
// Get the status of the managed rules
|
||||
const status = await manager.getStatus();
|
||||
|
||||
// Verify there are no active rules
|
||||
expect(Object.keys(status).length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
162
test/test.nftables-status.ts
Normal file
162
test/test.nftables-status.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { NFTablesManager } from '../ts/proxies/smart-proxy/nftables-manager.js';
|
||||
import { createNfTablesRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Check if we have root privileges
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await exec('id -u');
|
||||
return stdout.trim() === '0';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip tests if not root
|
||||
const isRoot = await checkRootPrivileges();
|
||||
if (!isRoot) {
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
console.log('NFTables status tests require root privileges');
|
||||
console.log('Skipping NFTables status tests');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
tap.test('NFTablesManager status functionality', async () => {
|
||||
const nftablesManager = new NFTablesManager();
|
||||
|
||||
// Create test routes
|
||||
const testRoutes = [
|
||||
createNfTablesRoute('test-route-1', { host: 'localhost', port: 8080 }, { ports: 9080 }),
|
||||
createNfTablesRoute('test-route-2', { host: 'localhost', port: 8081 }, { ports: 9081 }),
|
||||
createNfTablesRoute('test-route-3', { host: 'localhost', port: 8082 }, {
|
||||
ports: 9082,
|
||||
ipAllowList: ['127.0.0.1', '192.168.1.0/24']
|
||||
})
|
||||
];
|
||||
|
||||
// Get initial status (should be empty)
|
||||
let status = await nftablesManager.getStatus();
|
||||
expect(Object.keys(status).length).toEqual(0);
|
||||
|
||||
// Provision routes
|
||||
for (const route of testRoutes) {
|
||||
await nftablesManager.provisionRoute(route);
|
||||
}
|
||||
|
||||
// Get status after provisioning
|
||||
status = await nftablesManager.getStatus();
|
||||
expect(Object.keys(status).length).toEqual(3);
|
||||
|
||||
// Check status structure
|
||||
for (const routeStatus of Object.values(status)) {
|
||||
expect(routeStatus).toHaveProperty('active');
|
||||
expect(routeStatus).toHaveProperty('ruleCount');
|
||||
expect(routeStatus).toHaveProperty('lastUpdate');
|
||||
expect(routeStatus.active).toBeTrue();
|
||||
}
|
||||
|
||||
// Deprovision one route
|
||||
await nftablesManager.deprovisionRoute(testRoutes[0]);
|
||||
|
||||
// Check status after deprovisioning
|
||||
status = await nftablesManager.getStatus();
|
||||
expect(Object.keys(status).length).toEqual(2);
|
||||
|
||||
// Cleanup remaining routes
|
||||
await nftablesManager.stop();
|
||||
|
||||
// Final status should be empty
|
||||
status = await nftablesManager.getStatus();
|
||||
expect(Object.keys(status).length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('SmartProxy getNfTablesStatus functionality', async () => {
|
||||
const smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesRoute('proxy-test-1', { host: 'localhost', port: 3000 }, { ports: 3001 }),
|
||||
createNfTablesRoute('proxy-test-2', { host: 'localhost', port: 3002 }, { ports: 3003 }),
|
||||
// Include a non-NFTables route to ensure it's not included in the status
|
||||
{
|
||||
name: 'non-nftables-route',
|
||||
match: { ports: 3004 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3005 }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await smartProxy.start();
|
||||
|
||||
// Get NFTables status
|
||||
const status = await smartProxy.getNfTablesStatus();
|
||||
|
||||
// Should only have 2 NFTables routes
|
||||
const statusKeys = Object.keys(status);
|
||||
expect(statusKeys.length).toEqual(2);
|
||||
|
||||
// Check that both NFTables routes are in the status
|
||||
const routeIds = statusKeys.sort();
|
||||
expect(routeIds).toContain('proxy-test-1:3001');
|
||||
expect(routeIds).toContain('proxy-test-2:3003');
|
||||
|
||||
// Verify status structure
|
||||
for (const [routeId, routeStatus] of Object.entries(status)) {
|
||||
expect(routeStatus).toHaveProperty('active', true);
|
||||
expect(routeStatus).toHaveProperty('ruleCount');
|
||||
expect(routeStatus.ruleCount).toHaveProperty('total');
|
||||
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Stop the proxy
|
||||
await smartProxy.stop();
|
||||
|
||||
// After stopping, status should be empty
|
||||
const finalStatus = await smartProxy.getNfTablesStatus();
|
||||
expect(Object.keys(finalStatus).length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('NFTables route update status tracking', async () => {
|
||||
const smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesRoute('update-test', { host: 'localhost', port: 4000 }, { ports: 4001 })
|
||||
]
|
||||
});
|
||||
|
||||
await smartProxy.start();
|
||||
|
||||
// Get initial status
|
||||
let status = await smartProxy.getNfTablesStatus();
|
||||
expect(Object.keys(status).length).toEqual(1);
|
||||
const initialUpdate = status['update-test:4001'].lastUpdate;
|
||||
|
||||
// Wait a moment
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Update the route
|
||||
await smartProxy.updateRoutes([
|
||||
createNfTablesRoute('update-test', { host: 'localhost', port: 4002 }, { ports: 4001 })
|
||||
]);
|
||||
|
||||
// Get status after update
|
||||
status = await smartProxy.getNfTablesStatus();
|
||||
expect(Object.keys(status).length).toEqual(1);
|
||||
const updatedTime = status['update-test:4001'].lastUpdate;
|
||||
|
||||
// The update time should be different
|
||||
expect(updatedTime.getTime()).toBeGreaterThan(initialUpdate.getTime());
|
||||
|
||||
await smartProxy.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -82,7 +82,7 @@ tap.test('setup port proxy test environment', async () => {
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
allowedIps: ['127.0.0.1']
|
||||
ipAllowList: ['127.0.0.1']
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -121,7 +121,7 @@ tap.test('should forward TCP connections to custom host', async () => {
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
allowedIps: ['127.0.0.1']
|
||||
ipAllowList: ['127.0.0.1']
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -166,7 +166,7 @@ tap.test('should forward connections to custom IP', async () => {
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
allowedIps: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
ipAllowList: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -261,7 +261,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
allowedIps: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
ipAllowList: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -282,7 +282,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
allowedIps: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
ipAllowList: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -320,7 +320,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
allowedIps: ['127.0.0.1']
|
||||
ipAllowList: ['127.0.0.1']
|
||||
},
|
||||
preserveSourceIP: true
|
||||
},
|
||||
@ -343,7 +343,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
allowedIps: ['127.0.0.1']
|
||||
ipAllowList: ['127.0.0.1']
|
||||
},
|
||||
preserveSourceIP: true
|
||||
},
|
||||
|
@ -209,18 +209,18 @@ export function matchIpPattern(pattern: string, ip: string): boolean {
|
||||
* Match an IP against allowed and blocked IP patterns
|
||||
*
|
||||
* @param ip IP to check
|
||||
* @param allowedIps Array of allowed IP patterns
|
||||
* @param blockedIps Array of blocked IP patterns
|
||||
* @param ipAllowList Array of allowed IP patterns
|
||||
* @param ipBlockList Array of blocked IP patterns
|
||||
* @returns Whether the IP is allowed
|
||||
*/
|
||||
export function isIpAuthorized(
|
||||
ip: string,
|
||||
allowedIps: string[] = ['*'],
|
||||
blockedIps: string[] = []
|
||||
ipAllowList: string[] = ['*'],
|
||||
ipBlockList: string[] = []
|
||||
): boolean {
|
||||
// Check blocked IPs first
|
||||
if (blockedIps.length > 0) {
|
||||
for (const pattern of blockedIps) {
|
||||
if (ipBlockList.length > 0) {
|
||||
for (const pattern of ipBlockList) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
return false; // IP is blocked
|
||||
}
|
||||
@ -228,13 +228,13 @@ export function isIpAuthorized(
|
||||
}
|
||||
|
||||
// If there are allowed IPs, check them
|
||||
if (allowedIps.length > 0) {
|
||||
if (ipAllowList.length > 0) {
|
||||
// Special case: if '*' is in allowed IPs, all non-blocked IPs are allowed
|
||||
if (allowedIps.includes('*')) {
|
||||
if (ipAllowList.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const pattern of allowedIps) {
|
||||
for (const pattern of ipAllowList) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
return true; // IP is allowed
|
||||
}
|
||||
|
@ -31,8 +31,8 @@ export interface NfTableProxyOptions {
|
||||
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
|
||||
ipAllowList?: string[]; // If provided, only these IPs are allowed
|
||||
ipBlockList?: string[]; // If provided, these IPs are blocked
|
||||
useIPSets?: boolean; // Use nftables sets for efficient IP management
|
||||
|
||||
// Rule management
|
||||
|
@ -134,8 +134,8 @@ export class NfTablesProxy {
|
||||
}
|
||||
};
|
||||
|
||||
validateIPs(settings.allowedSourceIPs);
|
||||
validateIPs(settings.bannedSourceIPs);
|
||||
validateIPs(settings.ipAllowList);
|
||||
validateIPs(settings.ipBlockList);
|
||||
|
||||
// Validate toHost - only allow hostnames or IPs
|
||||
if (settings.toHost) {
|
||||
@ -426,7 +426,7 @@ export class NfTablesProxy {
|
||||
* 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) {
|
||||
if (!this.settings.ipAllowList && !this.settings.ipBlockList) {
|
||||
return true; // Nothing to do
|
||||
}
|
||||
|
||||
@ -441,9 +441,9 @@ export class NfTablesProxy {
|
||||
// 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) {
|
||||
if (this.settings.ipBlockList && this.settings.ipBlockList.length > 0) {
|
||||
const setName = 'banned_ips';
|
||||
await this.createIPSet(family, setName, this.settings.bannedSourceIPs, setType as any);
|
||||
await this.createIPSet(family, setName, this.settings.ipBlockList, 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"`;
|
||||
@ -458,9 +458,9 @@ export class NfTablesProxy {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.settings.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) {
|
||||
if (this.settings.ipAllowList && this.settings.ipAllowList.length > 0) {
|
||||
const setName = 'allowed_ips';
|
||||
await this.createIPSet(family, setName, this.settings.allowedSourceIPs, setType as any);
|
||||
await this.createIPSet(family, setName, this.settings.ipAllowList, 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"`;
|
||||
@ -490,8 +490,8 @@ export class NfTablesProxy {
|
||||
// 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) {
|
||||
if (this.settings.ipBlockList && this.settings.ipBlockList.length > 0) {
|
||||
for (const ip of this.settings.ipBlockList) {
|
||||
// Skip IPv4 addresses for IPv6 rules and vice versa
|
||||
if (isIpv6 && ip.includes('.')) continue;
|
||||
if (!isIpv6 && ip.includes(':')) continue;
|
||||
@ -510,9 +510,9 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
// Allow specific IPs
|
||||
if (this.settings.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) {
|
||||
if (this.settings.ipAllowList && this.settings.ipAllowList.length > 0) {
|
||||
// Add rules to allow specific IPs
|
||||
for (const ip of this.settings.allowedSourceIPs) {
|
||||
for (const ip of this.settings.ipAllowList) {
|
||||
// Skip IPv4 addresses for IPv6 rules and vice versa
|
||||
if (isIpv6 && ip.includes('.')) continue;
|
||||
if (!isIpv6 && ip.includes(':')) continue;
|
||||
@ -1398,28 +1398,28 @@ export class NfTablesProxy {
|
||||
|
||||
// Source IP filters
|
||||
if (this.settings.useIPSets) {
|
||||
if (this.settings.bannedSourceIPs?.length) {
|
||||
if (this.settings.ipBlockList?.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 element ip ${this.tableName} banned_ips { ${this.settings.ipBlockList.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) {
|
||||
if (this.settings.ipAllowList?.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 element ip ${this.tableName} allowed_ips { ${this.settings.ipAllowList.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) {
|
||||
} else if (this.settings.ipBlockList?.length || this.settings.ipAllowList?.length) {
|
||||
// Traditional approach without IP sets
|
||||
if (this.settings.bannedSourceIPs?.length) {
|
||||
for (const ip of this.settings.bannedSourceIPs) {
|
||||
if (this.settings.ipBlockList?.length) {
|
||||
for (const ip of this.settings.ipBlockList) {
|
||||
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) {
|
||||
if (this.settings.ipAllowList?.length) {
|
||||
for (const ip of this.settings.ipAllowList) {
|
||||
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"`);
|
||||
|
@ -19,6 +19,7 @@ export { NetworkProxyBridge } from './network-proxy-bridge.js';
|
||||
// Export route-based components
|
||||
export { RouteManager } from './route-manager.js';
|
||||
export { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
export { NFTablesManager } from './nftables-manager.js';
|
||||
|
||||
// Export all helper functions from the utils directory
|
||||
export * from './utils/index.js';
|
||||
|
@ -142,4 +142,7 @@ export interface IConnectionRecord {
|
||||
// Browser connection tracking
|
||||
isBrowserConnection?: boolean; // Whether this connection appears to be from a browser
|
||||
domainSwitches?: number; // Number of times the domain has been switched on this connection
|
||||
|
||||
// NFTables tracking
|
||||
nftablesHandled?: boolean; // Whether this connection is being handled by NFTables at kernel level
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js';
|
||||
import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js';
|
||||
import type { PortRange } from '../../../proxies/nftables-proxy/models/interfaces.js';
|
||||
|
||||
/**
|
||||
* Supported action types for route configurations
|
||||
@ -259,6 +260,12 @@ export interface IRouteAction {
|
||||
backendProtocol?: 'http1' | 'http2';
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// Forwarding engine specification
|
||||
forwardingEngine?: 'node' | 'nftables';
|
||||
|
||||
// NFTables-specific options
|
||||
nftables?: INfTablesOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -275,6 +282,19 @@ export interface IRouteRateLimit {
|
||||
|
||||
// IRouteSecurity is defined above - unified definition is used for all routes
|
||||
|
||||
/**
|
||||
* NFTables-specific configuration options
|
||||
*/
|
||||
export interface INfTablesOptions {
|
||||
preserveSourceIP?: boolean; // Preserve original source IP address
|
||||
protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward
|
||||
maxRate?: string; // QoS rate limiting (e.g. "10mbps")
|
||||
priority?: number; // QoS priority (1-10, lower is higher priority)
|
||||
tableName?: string; // Optional custom table name
|
||||
useIPSets?: boolean; // Use IP sets for performance
|
||||
useAdvancedNAT?: boolean; // Use connection tracking for stateful NAT
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS configuration for a route
|
||||
*/
|
||||
|
268
ts/proxies/smart-proxy/nftables-manager.ts
Normal file
268
ts/proxies/smart-proxy/nftables-manager.ts
Normal file
@ -0,0 +1,268 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { NfTablesProxy } from '../nftables-proxy/nftables-proxy.js';
|
||||
import type {
|
||||
NfTableProxyOptions,
|
||||
PortRange,
|
||||
NfTablesStatus
|
||||
} from '../nftables-proxy/models/interfaces.js';
|
||||
import type {
|
||||
IRouteConfig,
|
||||
TPortRange,
|
||||
INfTablesOptions
|
||||
} from './models/route-types.js';
|
||||
import type { ISmartProxyOptions } from './models/interfaces.js';
|
||||
|
||||
/**
|
||||
* Manages NFTables rules based on SmartProxy route configurations
|
||||
*
|
||||
* This class bridges the gap between SmartProxy routes and the NFTablesProxy,
|
||||
* allowing high-performance kernel-level packet forwarding for routes that
|
||||
* specify NFTables as their forwarding engine.
|
||||
*/
|
||||
export class NFTablesManager {
|
||||
private rulesMap: Map<string, NfTablesProxy> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new NFTablesManager
|
||||
*
|
||||
* @param options The SmartProxy options
|
||||
*/
|
||||
constructor(private options: ISmartProxyOptions) {}
|
||||
|
||||
/**
|
||||
* Provision NFTables rules for a route
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns A promise that resolves to true if successful, false otherwise
|
||||
*/
|
||||
public async provisionRoute(route: IRouteConfig): Promise<boolean> {
|
||||
// Generate a unique ID for this route
|
||||
const routeId = this.generateRouteId(route);
|
||||
|
||||
// Skip if route doesn't use NFTables
|
||||
if (route.action.forwardingEngine !== 'nftables') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create NFTables options from route configuration
|
||||
const nftOptions = this.createNfTablesOptions(route);
|
||||
|
||||
// Create and start an NFTablesProxy instance
|
||||
const proxy = new NfTablesProxy(nftOptions);
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
this.rulesMap.set(routeId, proxy);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to provision NFTables rules for route ${route.name || 'unnamed'}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove NFTables rules for a route
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns A promise that resolves to true if successful, false otherwise
|
||||
*/
|
||||
public async deprovisionRoute(route: IRouteConfig): Promise<boolean> {
|
||||
const routeId = this.generateRouteId(route);
|
||||
|
||||
const proxy = this.rulesMap.get(routeId);
|
||||
if (!proxy) {
|
||||
return true; // Nothing to remove
|
||||
}
|
||||
|
||||
try {
|
||||
await proxy.stop();
|
||||
this.rulesMap.delete(routeId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to deprovision NFTables rules for route ${route.name || 'unnamed'}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NFTables rules when route changes
|
||||
*
|
||||
* @param oldRoute The previous route configuration
|
||||
* @param newRoute The new route configuration
|
||||
* @returns A promise that resolves to true if successful, false otherwise
|
||||
*/
|
||||
public async updateRoute(oldRoute: IRouteConfig, newRoute: IRouteConfig): Promise<boolean> {
|
||||
// Remove old rules and add new ones
|
||||
await this.deprovisionRoute(oldRoute);
|
||||
return this.provisionRoute(newRoute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a route
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns A unique ID string
|
||||
*/
|
||||
private generateRouteId(route: IRouteConfig): string {
|
||||
// Generate a unique ID based on route properties
|
||||
// Include the route name, match criteria, and a timestamp
|
||||
const matchStr = JSON.stringify({
|
||||
ports: route.match.ports,
|
||||
domains: route.match.domains
|
||||
});
|
||||
|
||||
return `${route.name || 'unnamed'}-${matchStr}-${route.id || Date.now().toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create NFTablesProxy options from a route configuration
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns NFTableProxyOptions object
|
||||
*/
|
||||
private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions {
|
||||
const { action } = route;
|
||||
|
||||
// Ensure we have a target
|
||||
if (!action.target) {
|
||||
throw new Error('Route must have a target to use NFTables forwarding');
|
||||
}
|
||||
|
||||
// Convert port specifications
|
||||
const fromPorts = this.expandPortRange(route.match.ports);
|
||||
|
||||
// Determine target port
|
||||
let toPorts: number | PortRange | Array<number | PortRange>;
|
||||
|
||||
if (action.target.port === 'preserve') {
|
||||
// 'preserve' means use the same ports as the source
|
||||
toPorts = fromPorts;
|
||||
} else if (typeof action.target.port === 'function') {
|
||||
// For function-based ports, we can't determine at setup time
|
||||
// Use the "preserve" approach and let NFTables handle it
|
||||
toPorts = fromPorts;
|
||||
} else {
|
||||
toPorts = action.target.port;
|
||||
}
|
||||
|
||||
// Determine target host
|
||||
let toHost: string;
|
||||
if (typeof action.target.host === 'function') {
|
||||
// Can't determine at setup time, use localhost as a placeholder
|
||||
// and rely on run-time handling
|
||||
toHost = 'localhost';
|
||||
} else if (Array.isArray(action.target.host)) {
|
||||
// Use first host for now - NFTables will do simple round-robin
|
||||
toHost = action.target.host[0];
|
||||
} else {
|
||||
toHost = action.target.host;
|
||||
}
|
||||
|
||||
// Create options
|
||||
const options: NfTableProxyOptions = {
|
||||
fromPort: fromPorts,
|
||||
toPort: toPorts,
|
||||
toHost: toHost,
|
||||
protocol: action.nftables?.protocol || 'tcp',
|
||||
preserveSourceIP: action.nftables?.preserveSourceIP !== undefined ?
|
||||
action.nftables.preserveSourceIP :
|
||||
this.options.preserveSourceIP,
|
||||
useIPSets: action.nftables?.useIPSets !== false,
|
||||
useAdvancedNAT: action.nftables?.useAdvancedNAT,
|
||||
enableLogging: this.options.enableDetailedLogging,
|
||||
deleteOnExit: true,
|
||||
tableName: action.nftables?.tableName || 'smartproxy'
|
||||
};
|
||||
|
||||
// Add security-related options
|
||||
const security = action.security || route.security;
|
||||
if (security?.ipAllowList?.length) {
|
||||
options.ipAllowList = security.ipAllowList;
|
||||
}
|
||||
|
||||
if (security?.ipBlockList?.length) {
|
||||
options.ipBlockList = security.ipBlockList;
|
||||
}
|
||||
|
||||
// Add QoS options
|
||||
if (action.nftables?.maxRate || action.nftables?.priority) {
|
||||
options.qos = {
|
||||
enabled: true,
|
||||
maxRate: action.nftables.maxRate,
|
||||
priority: action.nftables.priority
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand port range specifications
|
||||
*
|
||||
* @param ports The port range specification
|
||||
* @returns Expanded port range
|
||||
*/
|
||||
private expandPortRange(ports: TPortRange): number | PortRange | Array<number | PortRange> {
|
||||
// Process different port specifications
|
||||
if (typeof ports === 'number') {
|
||||
return ports;
|
||||
} else if (Array.isArray(ports)) {
|
||||
const result: Array<number | PortRange> = [];
|
||||
|
||||
for (const item of ports) {
|
||||
if (typeof item === 'number') {
|
||||
result.push(item);
|
||||
} else if ('from' in item && 'to' in item) {
|
||||
result.push({ from: item.from, to: item.to });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} else if (typeof ports === 'object' && ports !== null && 'from' in ports && 'to' in ports) {
|
||||
return { from: (ports as any).from, to: (ports as any).to };
|
||||
}
|
||||
|
||||
// Fallback to port 80 if something went wrong
|
||||
console.warn('Invalid port range specification, using port 80 as fallback');
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all managed rules
|
||||
*
|
||||
* @returns A promise that resolves to a record of NFTables status objects
|
||||
*/
|
||||
public async getStatus(): Promise<Record<string, NfTablesStatus>> {
|
||||
const result: Record<string, NfTablesStatus> = {};
|
||||
|
||||
for (const [routeId, proxy] of this.rulesMap.entries()) {
|
||||
result[routeId] = await proxy.getStatus();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is currently provisioned
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns True if the route is provisioned, false otherwise
|
||||
*/
|
||||
public isRouteProvisioned(route: IRouteConfig): boolean {
|
||||
const routeId = this.generateRouteId(route);
|
||||
return this.rulesMap.has(routeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all NFTables rules
|
||||
*
|
||||
* @returns A promise that resolves when all rules have been stopped
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
// Stop all NFTables proxies
|
||||
const stopPromises = Array.from(this.rulesMap.values()).map(proxy => proxy.stop());
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
this.rulesMap.clear();
|
||||
}
|
||||
}
|
@ -338,6 +338,22 @@ export class RouteConnectionHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this route uses NFTables for forwarding
|
||||
if (route.action.forwardingEngine === 'nftables') {
|
||||
// For NFTables routes, we don't need to do anything at the application level
|
||||
// The packet is forwarded at the kernel level
|
||||
|
||||
// Log the connection
|
||||
console.log(
|
||||
`[${connectionId}] Connection forwarded by NFTables: ${record.remoteIP} -> port ${record.localPort}`
|
||||
);
|
||||
|
||||
// Just close the socket in our application since it's handled at kernel level
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'nftables_handled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the route based on its action type
|
||||
switch (route.action.type) {
|
||||
case 'forward':
|
||||
@ -368,6 +384,45 @@ export class RouteConnectionHandler {
|
||||
const connectionId = record.id;
|
||||
const action = route.action;
|
||||
|
||||
// Check if this route uses NFTables for forwarding
|
||||
if (action.forwardingEngine === 'nftables') {
|
||||
// Log detailed information about NFTables-handled connection
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${record.id}] Connection forwarded by NFTables (kernel-level): ` +
|
||||
`${record.remoteIP}:${socket.remotePort} -> ${socket.localAddress}:${record.localPort}` +
|
||||
` (Route: "${route.name || 'unnamed'}", Domain: ${record.lockedDomain || 'n/a'})`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[${record.id}] NFTables forwarding: ${record.remoteIP} -> port ${record.localPort} (Route: "${route.name || 'unnamed'}")`
|
||||
);
|
||||
}
|
||||
|
||||
// Additional NFTables-specific logging if configured
|
||||
if (action.nftables) {
|
||||
const nftConfig = action.nftables;
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${record.id}] NFTables config: ` +
|
||||
`protocol=${nftConfig.protocol || 'tcp'}, ` +
|
||||
`preserveSourceIP=${nftConfig.preserveSourceIP || false}, ` +
|
||||
`priority=${nftConfig.priority || 'default'}, ` +
|
||||
`maxRate=${nftConfig.maxRate || 'unlimited'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This connection is handled at the kernel level, no need to process at application level
|
||||
// Close the socket gracefully in our application layer
|
||||
socket.end();
|
||||
|
||||
// Mark the connection as handled by NFTables for proper cleanup
|
||||
record.nftablesHandled = true;
|
||||
this.connectionManager.initiateCleanupOnce(record, 'nftables_handled');
|
||||
return;
|
||||
}
|
||||
|
||||
// We should have a target configuration for forwarding
|
||||
if (!action.target) {
|
||||
console.log(`[${connectionId}] Forward action missing target configuration`);
|
||||
|
@ -9,6 +9,7 @@ import { TimeoutManager } from './timeout-manager.js';
|
||||
import { PortManager } from './port-manager.js';
|
||||
import { RouteManager } from './route-manager.js';
|
||||
import { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
import { NFTablesManager } from './nftables-manager.js';
|
||||
|
||||
// External dependencies
|
||||
import { Port80Handler } from '../../http/port80/port80-handler.js';
|
||||
@ -50,6 +51,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
private timeoutManager: TimeoutManager;
|
||||
public routeManager: RouteManager; // Made public for route management
|
||||
private routeConnectionHandler: RouteConnectionHandler;
|
||||
private nftablesManager: NFTablesManager;
|
||||
|
||||
// Port80Handler for ACME certificate management
|
||||
private port80Handler: Port80Handler | null = null;
|
||||
@ -82,7 +84,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
* ],
|
||||
* defaults: {
|
||||
* target: { host: 'localhost', port: 8080 },
|
||||
* security: { allowedIps: ['*'] }
|
||||
* security: { ipAllowList: ['*'] }
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
@ -167,6 +169,9 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
|
||||
// Initialize port manager
|
||||
this.portManager = new PortManager(this.settings, this.routeConnectionHandler);
|
||||
|
||||
// Initialize NFTablesManager
|
||||
this.nftablesManager = new NFTablesManager(this.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -270,6 +275,13 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
// Get listening ports from RouteManager
|
||||
const listeningPorts = this.routeManager.getListeningPorts();
|
||||
|
||||
// Provision NFTables rules for routes that use NFTables
|
||||
for (const route of this.settings.routes) {
|
||||
if (route.action.forwardingEngine === 'nftables') {
|
||||
await this.nftablesManager.provisionRoute(route);
|
||||
}
|
||||
}
|
||||
|
||||
// Start port listeners using the PortManager
|
||||
await this.portManager.addPorts(listeningPorts);
|
||||
|
||||
@ -364,6 +376,10 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
await this.certProvisioner.stop();
|
||||
console.log('CertProvisioner stopped');
|
||||
}
|
||||
|
||||
// Stop NFTablesManager
|
||||
await this.nftablesManager.stop();
|
||||
console.log('NFTablesManager stopped');
|
||||
|
||||
// Stop the Port80Handler if running
|
||||
if (this.port80Handler) {
|
||||
@ -432,6 +448,39 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
|
||||
console.log(`Updating routes (${newRoutes.length} routes)`);
|
||||
|
||||
// Get existing routes that use NFTables
|
||||
const oldNfTablesRoutes = this.settings.routes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
// Get new routes that use NFTables
|
||||
const newNfTablesRoutes = newRoutes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
// Find routes to remove, update, or add
|
||||
for (const oldRoute of oldNfTablesRoutes) {
|
||||
const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
|
||||
|
||||
if (!newRoute) {
|
||||
// Route was removed
|
||||
await this.nftablesManager.deprovisionRoute(oldRoute);
|
||||
} else {
|
||||
// Route was updated
|
||||
await this.nftablesManager.updateRoute(oldRoute, newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Find new routes to add
|
||||
for (const newRoute of newNfTablesRoutes) {
|
||||
const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
|
||||
|
||||
if (!oldRoute) {
|
||||
// New route
|
||||
await this.nftablesManager.provisionRoute(newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Update routes in RouteManager
|
||||
this.routeManager.updateRoutes(newRoutes);
|
||||
|
||||
@ -440,6 +489,9 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
|
||||
// Update port listeners to match the new configuration
|
||||
await this.portManager.updatePorts(requiredPorts);
|
||||
|
||||
// Update settings with the new routes
|
||||
this.settings.routes = newRoutes;
|
||||
|
||||
// If NetworkProxy is initialized, resync the configurations
|
||||
if (this.networkProxyBridge.getNetworkProxy()) {
|
||||
@ -676,6 +728,13 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
return domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NFTables status
|
||||
*/
|
||||
public async getNfTablesStatus(): Promise<Record<string, any>> {
|
||||
return this.nftablesManager.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of certificates managed by Port80Handler
|
||||
*/
|
||||
|
@ -5,7 +5,8 @@
|
||||
* including helpers, validators, utilities, and patterns for working with routes.
|
||||
*/
|
||||
|
||||
// Route helpers have been consolidated in route-patterns.js
|
||||
// Export route helpers for creating route configurations
|
||||
export * from './route-helpers.js';
|
||||
|
||||
// Export route validators for validating route configurations
|
||||
export * from './route-validators.js';
|
||||
|
@ -16,6 +16,7 @@
|
||||
* - WebSocket routes (createWebSocketRoute)
|
||||
* - Port mapping routes (createPortMappingRoute, createOffsetPortMappingRoute)
|
||||
* - Dynamic routing (createDynamicRoute, createSmartLoadBalancer)
|
||||
* - NFTables routes (createNfTablesRoute, createNfTablesTerminateRoute)
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js';
|
||||
@ -618,4 +619,195 @@ export function createSmartLoadBalancer(options: {
|
||||
priority: options.priority,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NFTables-based route for high-performance packet forwarding
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createNfTablesRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
useTls?: boolean;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Determine if this is a name or domain
|
||||
let name: string;
|
||||
let domains: string | string[] | undefined;
|
||||
|
||||
if (Array.isArray(nameOrDomains) || (typeof nameOrDomains === 'string' && nameOrDomains.includes('.'))) {
|
||||
domains = nameOrDomains;
|
||||
name = Array.isArray(nameOrDomains) ? nameOrDomains[0] : nameOrDomains;
|
||||
} else {
|
||||
name = nameOrDomains;
|
||||
domains = undefined; // No domains
|
||||
}
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
domains,
|
||||
ports: options.ports || 80
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: target.host,
|
||||
port: target.port
|
||||
},
|
||||
forwardingEngine: 'nftables',
|
||||
nftables: {
|
||||
protocol: options.protocol || 'tcp',
|
||||
preserveSourceIP: options.preserveSourceIP,
|
||||
maxRate: options.maxRate,
|
||||
priority: options.priority,
|
||||
tableName: options.tableName,
|
||||
useIPSets: options.useIPSets,
|
||||
useAdvancedNAT: options.useAdvancedNAT
|
||||
}
|
||||
};
|
||||
|
||||
// Add security if allowed or blocked IPs are specified
|
||||
if (options.ipAllowList?.length || options.ipBlockList?.length) {
|
||||
action.security = {
|
||||
ipAllowList: options.ipAllowList,
|
||||
ipBlockList: options.ipBlockList
|
||||
};
|
||||
}
|
||||
|
||||
// Add TLS options if needed
|
||||
if (options.useTls) {
|
||||
action.tls = {
|
||||
mode: 'passthrough'
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
name,
|
||||
match,
|
||||
action
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NFTables-based TLS termination route
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createNfTablesTerminateRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Create basic NFTables route
|
||||
const route = createNfTablesRoute(
|
||||
nameOrDomains,
|
||||
target,
|
||||
{
|
||||
...options,
|
||||
ports: options.ports || 443,
|
||||
useTls: false
|
||||
}
|
||||
);
|
||||
|
||||
// Set TLS termination
|
||||
route.action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
};
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete NFTables-based HTTPS setup with HTTP redirect
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Array of two route configurations (HTTPS and HTTP redirect)
|
||||
*/
|
||||
export function createCompleteNfTablesHttpsServer(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
httpPort?: TPortRange;
|
||||
httpsPort?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
} = {}
|
||||
): IRouteConfig[] {
|
||||
// Create the HTTPS route using NFTables
|
||||
const httpsRoute = createNfTablesTerminateRoute(
|
||||
nameOrDomains,
|
||||
target,
|
||||
{
|
||||
...options,
|
||||
ports: options.httpsPort || 443
|
||||
}
|
||||
);
|
||||
|
||||
// Determine the domain(s) for HTTP redirect
|
||||
const domains = typeof nameOrDomains === 'string' && !nameOrDomains.includes('.')
|
||||
? undefined
|
||||
: nameOrDomains;
|
||||
|
||||
// Extract the HTTPS port for the redirect destination
|
||||
const httpsPort = typeof options.httpsPort === 'number'
|
||||
? options.httpsPort
|
||||
: Array.isArray(options.httpsPort) && typeof options.httpsPort[0] === 'number'
|
||||
? options.httpsPort[0]
|
||||
: 443;
|
||||
|
||||
// Create the HTTP redirect route (this uses standard forwarding, not NFTables)
|
||||
const httpRedirectRoute = createHttpToHttpsRedirect(
|
||||
domains as any, // Type cast needed since domains can be undefined now
|
||||
httpsPort,
|
||||
{
|
||||
match: {
|
||||
ports: options.httpPort || 80,
|
||||
domains: domains as any // Type cast needed since domains can be undefined now
|
||||
},
|
||||
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains || 'all domains'}`
|
||||
}
|
||||
);
|
||||
|
||||
return [httpsRoute, httpRedirectRoute];
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user