Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e8f92e86 | |||
| 9697ab3078 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiryDate": "2026-03-09T14:50:10.005Z",
|
"expiryDate": "2026-04-30T03:50:41.276Z",
|
||||||
"issueDate": "2025-12-09T14:50:10.005Z",
|
"issueDate": "2026-01-30T03:50:41.276Z",
|
||||||
"savedAt": "2025-12-09T14:50:10.006Z"
|
"savedAt": "2026-01-30T03:50:41.276Z"
|
||||||
}
|
}
|
||||||
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-30 - 22.2.0 - feat(proxies)
|
||||||
|
introduce nftables command executor and utilities, default certificate provider, expanded route/socket helper modules, and security improvements
|
||||||
|
|
||||||
|
- Added NftCommandExecutor with retry, temp-file support, sync execution, availability and conntrack checks.
|
||||||
|
- Refactored NfTablesProxy to use executor/utils (normalizePortSpec, validators, port normalizer, IP family filtering) and removed inline command/validation code.
|
||||||
|
- Introduced DefaultCertificateProvider to replace the deprecated CertificateManager; HttpProxy now uses DefaultCertificateProvider (CertificateManager exported as deprecated alias for compatibility).
|
||||||
|
- Added extensive route helper modules (http, https, api, load-balancer, nftables, dynamic, websocket, security, socket handlers) to simplify route creation and provide reusable patterns.
|
||||||
|
- Enhanced SecurityManagers: centralized security utilities (normalizeIP, isIPAuthorized, parseBasicAuthHeader, cleanup helpers), added validateAndTrackIP and JWT token verification, better IP normalization and rate tracking.
|
||||||
|
- Added many utility modules under ts/proxies/nftables-proxy/utils (command executor, port spec normalizer, rule validator) and exposed them via barrel export.
|
||||||
|
|
||||||
## 2025-12-09 - 22.1.1 - fix(tests)
|
## 2025-12-09 - 22.1.1 - fix(tests)
|
||||||
Normalize route configurations in tests to use name (remove id) and standardize route names
|
Normalize route configurations in tests to use name (remove id) and standardize route names
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "22.1.1",
|
"version": "22.2.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '22.1.1',
|
version: '22.2.0',
|
||||||
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,31 +148,66 @@ export class SharedSecurityManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate IP against rate limits and connection limits
|
* Validate IP against rate limits and connection limits
|
||||||
*
|
*
|
||||||
* @param ip - The IP address to validate
|
* @param ip - The IP address to validate
|
||||||
* @returns Result with allowed status and reason if blocked
|
* @returns Result with allowed status and reason if blocked
|
||||||
*/
|
*/
|
||||||
public validateIP(ip: string): IIpValidationResult {
|
public validateIP(ip: string): IIpValidationResult {
|
||||||
// Check connection count limit
|
// Check connection count limit
|
||||||
const connectionResult = checkMaxConnections(
|
const connectionResult = checkMaxConnections(
|
||||||
ip,
|
ip,
|
||||||
this.connectionsByIP,
|
this.connectionsByIP,
|
||||||
this.maxConnectionsPerIP
|
this.maxConnectionsPerIP
|
||||||
);
|
);
|
||||||
if (!connectionResult.allowed) {
|
if (!connectionResult.allowed) {
|
||||||
return connectionResult;
|
return connectionResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check connection rate limit
|
// Check connection rate limit
|
||||||
const rateResult = checkConnectionRate(
|
const rateResult = checkConnectionRate(
|
||||||
ip,
|
ip,
|
||||||
this.connectionsByIP,
|
this.connectionsByIP,
|
||||||
this.connectionRateLimitPerMinute
|
this.connectionRateLimitPerMinute
|
||||||
);
|
);
|
||||||
if (!rateResult.allowed) {
|
if (!rateResult.allowed) {
|
||||||
return rateResult;
|
return rateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically validate an IP and track the connection if allowed.
|
||||||
|
* This prevents race conditions where concurrent connections could bypass per-IP limits.
|
||||||
|
*
|
||||||
|
* @param ip - The IP address to validate
|
||||||
|
* @param connectionId - The connection ID to track if validation passes
|
||||||
|
* @returns Object with validation result and reason
|
||||||
|
*/
|
||||||
|
public validateAndTrackIP(ip: string, connectionId: string): IIpValidationResult {
|
||||||
|
// Check connection count limit BEFORE tracking
|
||||||
|
const connectionResult = checkMaxConnections(
|
||||||
|
ip,
|
||||||
|
this.connectionsByIP,
|
||||||
|
this.maxConnectionsPerIP
|
||||||
|
);
|
||||||
|
if (!connectionResult.allowed) {
|
||||||
|
return connectionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check connection rate limit
|
||||||
|
const rateResult = checkConnectionRate(
|
||||||
|
ip,
|
||||||
|
this.connectionsByIP,
|
||||||
|
this.connectionRateLimitPerMinute
|
||||||
|
);
|
||||||
|
if (!rateResult.allowed) {
|
||||||
|
return rateResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed - immediately track to prevent race conditions
|
||||||
|
this.trackConnectionByIP(ip, connectionId);
|
||||||
|
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +339,7 @@ export class SharedSecurityManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate HTTP Basic Authentication
|
* Validate HTTP Basic Authentication
|
||||||
*
|
*
|
||||||
* @param route - The route to check
|
* @param route - The route to check
|
||||||
* @param authHeader - The Authorization header
|
* @param authHeader - The Authorization header
|
||||||
* @returns Whether authentication is valid
|
* @returns Whether authentication is valid
|
||||||
@@ -314,26 +349,76 @@ export class SharedSecurityManager {
|
|||||||
if (!route.security?.basicAuth?.enabled) {
|
if (!route.security?.basicAuth?.enabled) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No auth header means auth failed
|
// No auth header means auth failed
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse auth header
|
// Parse auth header
|
||||||
const credentials = parseBasicAuthHeader(authHeader);
|
const credentials = parseBasicAuthHeader(authHeader);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check credentials against configured users
|
// Check credentials against configured users
|
||||||
const { username, password } = credentials;
|
const { username, password } = credentials;
|
||||||
const users = route.security.basicAuth.users;
|
const users = route.security.basicAuth.users;
|
||||||
|
|
||||||
return users.some(user =>
|
return users.some(user =>
|
||||||
user.username === username && user.password === password
|
user.username === username && user.password === password
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a JWT token against route configuration
|
||||||
|
*
|
||||||
|
* @param route - The route to verify the token for
|
||||||
|
* @param token - The JWT token to verify
|
||||||
|
* @returns True if the token is valid, false otherwise
|
||||||
|
*/
|
||||||
|
public verifyJwtToken(route: IRouteConfig, token: string): boolean {
|
||||||
|
if (!route.security?.jwtAuth?.enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwtAuth = route.security.jwtAuth;
|
||||||
|
|
||||||
|
// Verify structure (header.payload.signature)
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode payload
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check issuer
|
||||||
|
if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check audience
|
||||||
|
if (jwtAuth.audience && payload.aud !== jwtAuth.audience) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: In a real implementation, you'd also verify the signature
|
||||||
|
// using the secret and algorithm specified in jwtAuth.
|
||||||
|
// This requires a proper JWT library for cryptographic verification.
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger?.error?.(`Error verifying JWT: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up caches to prevent memory leaks
|
* Clean up caches to prevent memory leaks
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
|
||||||
import { type IHttpProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './models/types.js';
|
|
||||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated This class is deprecated. Use SmartCertManager instead.
|
|
||||||
*
|
|
||||||
* This is a stub implementation that maintains backward compatibility
|
|
||||||
* while the functionality has been moved to SmartCertManager.
|
|
||||||
*/
|
|
||||||
export class CertificateManager {
|
|
||||||
private defaultCertificates: { key: string; cert: string };
|
|
||||||
private certificateCache: Map<string, ICertificateEntry> = new Map();
|
|
||||||
private certificateStoreDir: string;
|
|
||||||
private logger: ILogger;
|
|
||||||
private httpsServer: plugins.https.Server | null = null;
|
|
||||||
private initialized = false;
|
|
||||||
|
|
||||||
constructor(private options: IHttpProxyOptions) {
|
|
||||||
this.certificateStoreDir = path.resolve(options.acme?.certificateStore || './certs');
|
|
||||||
this.logger = createLogger(options.logLevel || 'info');
|
|
||||||
|
|
||||||
this.logger.warn('CertificateManager is deprecated - use SmartCertManager instead');
|
|
||||||
|
|
||||||
// Initialize synchronously for backward compatibility but log warning
|
|
||||||
this.initializeSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous initialization for backward compatibility
|
|
||||||
* @deprecated This uses sync filesystem operations which block the event loop
|
|
||||||
*/
|
|
||||||
private initializeSync(): void {
|
|
||||||
// Ensure certificate store directory exists
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(this.certificateStoreDir)) {
|
|
||||||
fs.mkdirSync(this.certificateStoreDir, { recursive: true });
|
|
||||||
this.logger.info(`Created certificate store directory: ${this.certificateStoreDir}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to create certificate store directory: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadDefaultCertificates();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async initialization - preferred method
|
|
||||||
*/
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
if (this.initialized) return;
|
|
||||||
|
|
||||||
// Ensure certificate store directory exists
|
|
||||||
try {
|
|
||||||
await AsyncFileSystem.ensureDir(this.certificateStoreDir);
|
|
||||||
this.logger.info(`Ensured certificate store directory: ${this.certificateStoreDir}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to create certificate store directory: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.loadDefaultCertificatesAsync();
|
|
||||||
this.initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads default certificates from the filesystem
|
|
||||||
* @deprecated This uses sync filesystem operations which block the event loop
|
|
||||||
*/
|
|
||||||
public loadDefaultCertificates(): void {
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.defaultCertificates = {
|
|
||||||
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
|
||||||
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
|
||||||
};
|
|
||||||
this.logger.info('Loaded default certificates from filesystem (sync - deprecated)');
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to load default certificates: ${error}`);
|
|
||||||
this.generateSelfSignedCertificate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads default certificates from the filesystem asynchronously
|
|
||||||
*/
|
|
||||||
public async loadDefaultCertificatesAsync(): Promise<void> {
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [key, cert] = await Promise.all([
|
|
||||||
AsyncFileSystem.readFile(path.join(certPath, 'key.pem')),
|
|
||||||
AsyncFileSystem.readFile(path.join(certPath, 'cert.pem'))
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.defaultCertificates = { key, cert };
|
|
||||||
this.logger.info('Loaded default certificates from filesystem (async)');
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to load default certificates: ${error}`);
|
|
||||||
this.generateSelfSignedCertificate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates self-signed certificates as fallback
|
|
||||||
*/
|
|
||||||
private generateSelfSignedCertificate(): void {
|
|
||||||
// Generate a self-signed certificate using forge or similar
|
|
||||||
// For now, just use a placeholder
|
|
||||||
const selfSignedCert = `-----BEGIN CERTIFICATE-----
|
|
||||||
MIIBkTCB+wIJAKHHIgIIA0/cMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT
|
|
||||||
MB4XDTE0MDEwMTAwMDAwMFoXDTI0MDEwMTAwMDAwMFowDTELMAkGA1UEBhMCVVMw
|
|
||||||
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMRiH0VwnOH3jCV7c6JFZWYrvuqy
|
|
||||||
-----END CERTIFICATE-----`;
|
|
||||||
|
|
||||||
const selfSignedKey = `-----BEGIN PRIVATE KEY-----
|
|
||||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMRiH0VwnOH3jCV7
|
|
||||||
c6JFZWYrvuqyALCLXj0pcr1iqNdHjegNXnkl5zjdaUjq4edNOKl7M1AlFiYjG2xk
|
|
||||||
-----END PRIVATE KEY-----`;
|
|
||||||
|
|
||||||
this.defaultCertificates = {
|
|
||||||
key: selfSignedKey,
|
|
||||||
cert: selfSignedCert
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.warn('Using self-signed certificate as fallback');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the default certificates
|
|
||||||
*/
|
|
||||||
public getDefaultCertificates(): { key: string; cert: string } {
|
|
||||||
return this.defaultCertificates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use SmartCertManager instead
|
|
||||||
*/
|
|
||||||
public setExternalPort80Handler(handler: any): void {
|
|
||||||
this.logger.warn('setExternalPort80Handler is deprecated - use SmartCertManager instead');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use SmartCertManager instead
|
|
||||||
*/
|
|
||||||
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
|
||||||
this.logger.warn('updateRoutes is deprecated - use SmartCertManager instead');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles SNI callback to provide appropriate certificate
|
|
||||||
*/
|
|
||||||
public handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void {
|
|
||||||
const certificate = this.getCachedCertificate(domain);
|
|
||||||
|
|
||||||
if (certificate) {
|
|
||||||
const context = plugins.tls.createSecureContext({
|
|
||||||
key: certificate.key,
|
|
||||||
cert: certificate.cert
|
|
||||||
});
|
|
||||||
cb(null, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use default certificate if no domain-specific certificate found
|
|
||||||
const defaultContext = plugins.tls.createSecureContext({
|
|
||||||
key: this.defaultCertificates.key,
|
|
||||||
cert: this.defaultCertificates.cert
|
|
||||||
});
|
|
||||||
cb(null, defaultContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a certificate in the cache
|
|
||||||
*/
|
|
||||||
public updateCertificate(domain: string, cert: string, key: string): void {
|
|
||||||
this.certificateCache.set(domain, {
|
|
||||||
cert,
|
|
||||||
key,
|
|
||||||
expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.info(`Certificate updated for ${domain}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a cached certificate
|
|
||||||
*/
|
|
||||||
private getCachedCertificate(domain: string): ICertificateEntry | null {
|
|
||||||
return this.certificateCache.get(domain) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use SmartCertManager instead
|
|
||||||
*/
|
|
||||||
public async initializePort80Handler(): Promise<any> {
|
|
||||||
this.logger.warn('initializePort80Handler is deprecated - use SmartCertManager instead');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use SmartCertManager instead
|
|
||||||
*/
|
|
||||||
public async stopPort80Handler(): Promise<void> {
|
|
||||||
this.logger.warn('stopPort80Handler is deprecated - use SmartCertManager instead');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use SmartCertManager instead
|
|
||||||
*/
|
|
||||||
public registerDomainsWithPort80Handler(domains: string[]): void {
|
|
||||||
this.logger.warn('registerDomainsWithPort80Handler is deprecated - use SmartCertManager instead');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use SmartCertManager instead
|
|
||||||
*/
|
|
||||||
public registerRoutesWithPort80Handler(routes: IRouteConfig[]): void {
|
|
||||||
this.logger.warn('registerRoutesWithPort80Handler is deprecated - use SmartCertManager instead');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the HTTPS server for certificate updates
|
|
||||||
*/
|
|
||||||
public setHttpsServer(server: plugins.https.Server): void {
|
|
||||||
this.httpsServer = server;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets statistics for metrics
|
|
||||||
*/
|
|
||||||
public getStats() {
|
|
||||||
return {
|
|
||||||
cachedCertificates: this.certificateCache.size,
|
|
||||||
defaultCertEnabled: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
150
ts/proxies/http-proxy/default-certificates.ts
Normal file
150
ts/proxies/http-proxy/default-certificates.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||||
|
import type { ILogger, ICertificateEntry } from './models/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for default certificate data
|
||||||
|
*/
|
||||||
|
export interface IDefaultCertificates {
|
||||||
|
key: string;
|
||||||
|
cert: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides default SSL certificates for HttpProxy.
|
||||||
|
* This is a minimal replacement for the deprecated CertificateManager.
|
||||||
|
*
|
||||||
|
* For production certificate management, use SmartCertManager instead.
|
||||||
|
*/
|
||||||
|
export class DefaultCertificateProvider {
|
||||||
|
private defaultCertificates: IDefaultCertificates | null = null;
|
||||||
|
private certificateCache: Map<string, ICertificateEntry> = new Map();
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(private logger?: ILogger) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load default certificates asynchronously (preferred)
|
||||||
|
*/
|
||||||
|
public async loadDefaultCertificatesAsync(): Promise<IDefaultCertificates> {
|
||||||
|
if (this.defaultCertificates) {
|
||||||
|
return this.defaultCertificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [key, cert] = await Promise.all([
|
||||||
|
AsyncFileSystem.readFile(path.join(certPath, 'key.pem')),
|
||||||
|
AsyncFileSystem.readFile(path.join(certPath, 'cert.pem'))
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.defaultCertificates = { key, cert };
|
||||||
|
this.logger?.info?.('Loaded default certificates from filesystem');
|
||||||
|
this.initialized = true;
|
||||||
|
return this.defaultCertificates;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger?.warn?.(`Failed to load default certificates: ${error}`);
|
||||||
|
this.defaultCertificates = this.generateFallbackCertificate();
|
||||||
|
this.initialized = true;
|
||||||
|
return this.defaultCertificates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load default certificates synchronously (for backward compatibility)
|
||||||
|
* @deprecated Use loadDefaultCertificatesAsync instead
|
||||||
|
*/
|
||||||
|
public loadDefaultCertificatesSync(): IDefaultCertificates {
|
||||||
|
if (this.defaultCertificates) {
|
||||||
|
return this.defaultCertificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.defaultCertificates = {
|
||||||
|
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
||||||
|
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
||||||
|
};
|
||||||
|
this.logger?.info?.('Loaded default certificates from filesystem (sync)');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger?.warn?.(`Failed to load default certificates: ${error}`);
|
||||||
|
this.defaultCertificates = this.generateFallbackCertificate();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
return this.defaultCertificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the default certificates (loads synchronously if not already loaded)
|
||||||
|
*/
|
||||||
|
public getDefaultCertificates(): IDefaultCertificates {
|
||||||
|
if (!this.defaultCertificates) {
|
||||||
|
return this.loadDefaultCertificatesSync();
|
||||||
|
}
|
||||||
|
return this.defaultCertificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a certificate in the cache
|
||||||
|
*/
|
||||||
|
public updateCertificate(domain: string, cert: string, key: string): void {
|
||||||
|
this.certificateCache.set(domain, {
|
||||||
|
cert,
|
||||||
|
key,
|
||||||
|
expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger?.info?.(`Certificate updated for ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a cached certificate
|
||||||
|
*/
|
||||||
|
public getCachedCertificate(domain: string): ICertificateEntry | null {
|
||||||
|
return this.certificateCache.get(domain) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets statistics for metrics
|
||||||
|
*/
|
||||||
|
public getStats(): { cachedCertificates: number; defaultCertEnabled: boolean } {
|
||||||
|
return {
|
||||||
|
cachedCertificates: this.certificateCache.size,
|
||||||
|
defaultCertEnabled: this.defaultCertificates !== null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fallback self-signed certificate placeholder
|
||||||
|
* Note: This is just a placeholder - real apps should provide proper certificates
|
||||||
|
*/
|
||||||
|
private generateFallbackCertificate(): IDefaultCertificates {
|
||||||
|
this.logger?.warn?.('Using fallback self-signed certificate placeholder');
|
||||||
|
|
||||||
|
// Minimal self-signed certificate for fallback only
|
||||||
|
// In production, proper certificates should be provided via SmartCertManager
|
||||||
|
const selfSignedCert = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBkTCB+wIJAKHHIgIIA0/cMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT
|
||||||
|
MB4XDTE0MDEwMTAwMDAwMFoXDTI0MDEwMTAwMDAwMFowDTELMAkGA1UEBhMCVVMw
|
||||||
|
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMRiH0VwnOH3jCV7c6JFZWYrvuqy
|
||||||
|
-----END CERTIFICATE-----`;
|
||||||
|
|
||||||
|
const selfSignedKey = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMRiH0VwnOH3jCV7
|
||||||
|
c6JFZWYrvuqyALCLXj0pcr1iqNdHjegNXnkl5zjdaUjq4edNOKl7M1AlFiYjG2xk
|
||||||
|
-----END PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: selfSignedKey,
|
||||||
|
cert: selfSignedCert
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||||
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
||||||
import { createBaseRouteContext } from '../../core/models/route-context.js';
|
import { createBaseRouteContext } from '../../core/models/route-context.js';
|
||||||
import { CertificateManager } from './certificate-manager.js';
|
import { DefaultCertificateProvider } from './default-certificates.js';
|
||||||
import { ConnectionPool } from './connection-pool.js';
|
import { ConnectionPool } from './connection-pool.js';
|
||||||
import { RequestHandler, type IMetricsTracker } from './request-handler.js';
|
import { RequestHandler, type IMetricsTracker } from './request-handler.js';
|
||||||
import { WebSocketHandler } from './websocket-handler.js';
|
import { WebSocketHandler } from './websocket-handler.js';
|
||||||
@@ -38,7 +38,7 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
public httpsServer: plugins.http2.Http2SecureServer;
|
public httpsServer: plugins.http2.Http2SecureServer;
|
||||||
|
|
||||||
// Core components
|
// Core components
|
||||||
private certificateManager: CertificateManager;
|
private defaultCertProvider: DefaultCertificateProvider;
|
||||||
private connectionPool: ConnectionPool;
|
private connectionPool: ConnectionPool;
|
||||||
private requestHandler: RequestHandler;
|
private requestHandler: RequestHandler;
|
||||||
private webSocketHandler: WebSocketHandler;
|
private webSocketHandler: WebSocketHandler;
|
||||||
@@ -126,7 +126,7 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Initialize other components
|
// Initialize other components
|
||||||
this.certificateManager = new CertificateManager(this.options);
|
this.defaultCertProvider = new DefaultCertificateProvider(this.logger);
|
||||||
this.connectionPool = new ConnectionPool(this.options);
|
this.connectionPool = new ConnectionPool(this.options);
|
||||||
this.requestHandler = new RequestHandler(
|
this.requestHandler = new RequestHandler(
|
||||||
this.options,
|
this.options,
|
||||||
@@ -237,10 +237,11 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
this.startTime = Date.now();
|
this.startTime = Date.now();
|
||||||
|
|
||||||
// Create HTTP/2 server with HTTP/1 fallback
|
// Create HTTP/2 server with HTTP/1 fallback
|
||||||
|
const defaultCerts = this.defaultCertProvider.getDefaultCertificates();
|
||||||
this.httpsServer = plugins.http2.createSecureServer(
|
this.httpsServer = plugins.http2.createSecureServer(
|
||||||
{
|
{
|
||||||
key: this.certificateManager.getDefaultCertificates().key,
|
key: defaultCerts.key,
|
||||||
cert: this.certificateManager.getDefaultCertificates().cert,
|
cert: defaultCerts.cert,
|
||||||
allowHTTP1: true,
|
allowHTTP1: true,
|
||||||
ALPNProtocols: ['h2', 'http/1.1']
|
ALPNProtocols: ['h2', 'http/1.1']
|
||||||
}
|
}
|
||||||
@@ -258,9 +259,6 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
this.requestHandler.handleRequest(req, res);
|
this.requestHandler.handleRequest(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Share server with certificate manager for dynamic contexts
|
|
||||||
// Cast to https.Server as Http2SecureServer is compatible for certificate contexts
|
|
||||||
this.certificateManager.setHttpsServer(this.httpsServer as any);
|
|
||||||
// Setup WebSocket support on HTTP/1 fallback
|
// Setup WebSocket support on HTTP/1 fallback
|
||||||
this.webSocketHandler.initialize(this.httpsServer as any);
|
this.webSocketHandler.initialize(this.httpsServer as any);
|
||||||
// Start metrics logging
|
// Start metrics logging
|
||||||
@@ -506,10 +504,6 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
this.requestHandler.securityManager.setRoutes(routes);
|
this.requestHandler.securityManager.setRoutes(routes);
|
||||||
this.routes = routes;
|
this.routes = routes;
|
||||||
|
|
||||||
// Directly update the certificate manager with the new routes
|
|
||||||
// This will extract domains and handle certificate provisioning
|
|
||||||
this.certificateManager.updateRoutes(routes);
|
|
||||||
|
|
||||||
// Collect all domains and certificates for configuration
|
// Collect all domains and certificates for configuration
|
||||||
const currentHostnames = new Set<string>();
|
const currentHostnames = new Set<string>();
|
||||||
const certificateUpdates = new Map<string, { cert: string, key: string }>();
|
const certificateUpdates = new Map<string, { cert: string, key: string }>();
|
||||||
@@ -548,7 +542,7 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
// Update certificate cache with any static certificates
|
// Update certificate cache with any static certificates
|
||||||
for (const [domain, certData] of certificateUpdates.entries()) {
|
for (const [domain, certData] of certificateUpdates.entries()) {
|
||||||
try {
|
try {
|
||||||
this.certificateManager.updateCertificate(
|
this.defaultCertProvider.updateCertificate(
|
||||||
domain,
|
domain,
|
||||||
certData.cert,
|
certData.cert,
|
||||||
certData.key
|
certData.key
|
||||||
@@ -663,7 +657,7 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
expiryDate?: Date
|
expiryDate?: Date
|
||||||
): void {
|
): void {
|
||||||
this.logger.info(`Updating certificate for ${domain}`);
|
this.logger.info(`Updating certificate for ${domain}`);
|
||||||
this.certificateManager.updateCertificate(domain, certificate, privateKey);
|
this.defaultCertProvider.updateCertificate(domain, certificate, privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ export * from './models/index.js';
|
|||||||
|
|
||||||
// Export HttpProxy and supporting classes
|
// Export HttpProxy and supporting classes
|
||||||
export { HttpProxy } from './http-proxy.js';
|
export { HttpProxy } from './http-proxy.js';
|
||||||
export { CertificateManager } from './certificate-manager.js';
|
export { DefaultCertificateProvider } from './default-certificates.js';
|
||||||
export { ConnectionPool } from './connection-pool.js';
|
export { ConnectionPool } from './connection-pool.js';
|
||||||
export { RequestHandler } from './request-handler.js';
|
export { RequestHandler } from './request-handler.js';
|
||||||
export type { IMetricsTracker, MetricsTracker } from './request-handler.js';
|
export type { IMetricsTracker, MetricsTracker } from './request-handler.js';
|
||||||
export { WebSocketHandler } from './websocket-handler.js';
|
export { WebSocketHandler } from './websocket-handler.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use DefaultCertificateProvider instead. This alias is for backward compatibility.
|
||||||
|
*/
|
||||||
|
export { DefaultCertificateProvider as CertificateManager } from './default-certificates.js';
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import type { ILogger } from './models/types.js';
|
import type { ILogger } from './models/types.js';
|
||||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||||
|
import {
|
||||||
|
isIPAuthorized,
|
||||||
|
normalizeIP,
|
||||||
|
parseBasicAuthHeader,
|
||||||
|
cleanupExpiredRateLimits,
|
||||||
|
type IRateLimitInfo
|
||||||
|
} from '../../core/utils/security-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages security features for the NetworkProxy
|
* Manages security features for the HttpProxy
|
||||||
* Implements Phase 5.4: Security features like IP filtering and rate limiting
|
* Implements IP filtering, rate limiting, and authentication.
|
||||||
|
* Uses shared utilities from security-utils.ts.
|
||||||
*/
|
*/
|
||||||
export class SecurityManager {
|
export class SecurityManager {
|
||||||
// Cache IP filtering results to avoid constant regex matching
|
// Cache IP filtering results to avoid constant regex matching
|
||||||
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
|
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
|
||||||
|
|
||||||
// Store rate limits per route and key
|
// Store rate limits per route and key
|
||||||
private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = new Map();
|
private rateLimits: Map<string, Map<string, IRateLimitInfo>> = new Map();
|
||||||
|
|
||||||
// Connection tracking by IP
|
// Connection tracking by IP
|
||||||
private connectionsByIP: Map<string, Set<string>> = new Map();
|
private connectionsByIP: Map<string, Set<string>> = new Map();
|
||||||
private connectionRateByIP: Map<string, number[]> = new Map();
|
private connectionRateByIP: Map<string, number[]> = new Map();
|
||||||
|
|
||||||
constructor(private logger: ILogger, private routes: IRouteConfig[] = [], private maxConnectionsPerIP: number = 100, private connectionRateLimitPerMinute: number = 300) {
|
constructor(
|
||||||
|
private logger: ILogger,
|
||||||
|
private routes: IRouteConfig[] = [],
|
||||||
|
private maxConnectionsPerIP: number = 100,
|
||||||
|
private connectionRateLimitPerMinute: number = 300
|
||||||
|
) {
|
||||||
// Start periodic cleanup for connection tracking
|
// Start periodic cleanup for connection tracking
|
||||||
this.startPeriodicIpCleanup();
|
this.startPeriodicIpCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the routes configuration
|
* Update the routes configuration
|
||||||
*/
|
*/
|
||||||
@@ -31,10 +43,10 @@ export class SecurityManager {
|
|||||||
// Reset caches when routes change
|
// Reset caches when routes change
|
||||||
this.ipFilterCache.clear();
|
this.ipFilterCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a client is allowed to access a specific route
|
* Check if a client is allowed to access a specific route
|
||||||
*
|
*
|
||||||
* @param route The route to check access for
|
* @param route The route to check access for
|
||||||
* @param context The route context with client information
|
* @param context The route context with client information
|
||||||
* @returns True if access is allowed, false otherwise
|
* @returns True if access is allowed, false otherwise
|
||||||
@@ -43,26 +55,26 @@ export class SecurityManager {
|
|||||||
if (!route.security) {
|
if (!route.security) {
|
||||||
return true; // No security restrictions
|
return true; // No security restrictions
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IP filtering ---
|
// --- IP filtering ---
|
||||||
if (!this.isIpAllowed(route, context.clientIp)) {
|
if (!this.isIpAllowed(route, context.clientIp)) {
|
||||||
this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || route.id || 'unnamed'}`);
|
this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || 'unnamed'}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rate limiting ---
|
// --- Rate limiting ---
|
||||||
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
|
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
|
||||||
this.logger.debug(`Rate limit exceeded for route ${route.name || route.id || 'unnamed'}`);
|
this.logger.debug(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Basic Auth (handled at HTTP level) ---
|
// --- Basic Auth (handled at HTTP level) ---
|
||||||
// Basic auth is not checked here as it requires HTTP headers
|
// Basic auth is not checked here as it requires HTTP headers
|
||||||
// and is handled in the RequestHandler
|
// and is handled in the RequestHandler
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an IP is allowed based on route security settings
|
* Check if an IP is allowed based on route security settings
|
||||||
*/
|
*/
|
||||||
@@ -70,94 +82,32 @@ export class SecurityManager {
|
|||||||
if (!route.security) {
|
if (!route.security) {
|
||||||
return true; // No security restrictions
|
return true; // No security restrictions
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeId = route.id || route.name || 'unnamed';
|
const routeId = route.name || 'unnamed';
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if (!this.ipFilterCache.has(routeId)) {
|
if (!this.ipFilterCache.has(routeId)) {
|
||||||
this.ipFilterCache.set(routeId, new Map());
|
this.ipFilterCache.set(routeId, new Map());
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeCache = this.ipFilterCache.get(routeId)!;
|
const routeCache = this.ipFilterCache.get(routeId)!;
|
||||||
if (routeCache.has(clientIp)) {
|
if (routeCache.has(clientIp)) {
|
||||||
return routeCache.get(clientIp)!;
|
return routeCache.get(clientIp)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowed = true;
|
// Use shared utility for IP authorization
|
||||||
|
const allowed = isIPAuthorized(
|
||||||
// Check block list first (deny has priority over allow)
|
clientIp,
|
||||||
if (route.security.ipBlockList && route.security.ipBlockList.length > 0) {
|
route.security.ipAllowList,
|
||||||
if (this.ipMatchesPattern(clientIp, route.security.ipBlockList)) {
|
route.security.ipBlockList
|
||||||
allowed = false;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check allow list (overrides block list if specified)
|
|
||||||
if (route.security.ipAllowList && route.security.ipAllowList.length > 0) {
|
|
||||||
// If allow list is specified, IP must match an entry to be allowed
|
|
||||||
allowed = this.ipMatchesPattern(clientIp, route.security.ipAllowList);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
routeCache.set(clientIp, allowed);
|
routeCache.set(clientIp, allowed);
|
||||||
|
|
||||||
return allowed;
|
return allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if IP matches any pattern in the list
|
|
||||||
*/
|
|
||||||
private ipMatchesPattern(ip: string, patterns: string[]): boolean {
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
// CIDR notation
|
|
||||||
if (pattern.includes('/')) {
|
|
||||||
if (this.ipMatchesCidr(ip, pattern)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Wildcard notation
|
|
||||||
else if (pattern.includes('*')) {
|
|
||||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
||||||
if (regex.test(ip)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Exact match
|
|
||||||
else if (pattern === ip) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if IP matches CIDR notation
|
|
||||||
* Very basic implementation - for production use, consider a dedicated IP library
|
|
||||||
*/
|
|
||||||
private ipMatchesCidr(ip: string, cidr: string): boolean {
|
|
||||||
try {
|
|
||||||
const [subnet, bits] = cidr.split('/');
|
|
||||||
const mask = parseInt(bits, 10);
|
|
||||||
|
|
||||||
// Convert IP to numeric format
|
|
||||||
const ipParts = ip.split('.').map(part => parseInt(part, 10));
|
|
||||||
const subnetParts = subnet.split('.').map(part => parseInt(part, 10));
|
|
||||||
|
|
||||||
// Calculate the numeric IP and subnet
|
|
||||||
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
|
||||||
const subnetNum = (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
|
|
||||||
|
|
||||||
// Calculate the mask
|
|
||||||
const maskNum = ~((1 << (32 - mask)) - 1);
|
|
||||||
|
|
||||||
// Check if IP is in subnet
|
|
||||||
return (ipNum & maskNum) === (subnetNum & maskNum);
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.error(`Invalid CIDR notation: ${cidr}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if request is within rate limit
|
* Check if request is within rate limit
|
||||||
*/
|
*/
|
||||||
@@ -165,13 +115,13 @@ export class SecurityManager {
|
|||||||
if (!route.security?.rateLimit?.enabled) {
|
if (!route.security?.rateLimit?.enabled) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rateLimit = route.security.rateLimit;
|
const rateLimit = route.security.rateLimit;
|
||||||
const routeId = route.id || route.name || 'unnamed';
|
const routeId = route.name || 'unnamed';
|
||||||
|
|
||||||
// Determine rate limit key (by IP, path, or header)
|
// Determine rate limit key (by IP, path, or header)
|
||||||
let key = context.clientIp; // Default to IP
|
let key = context.clientIp; // Default to IP
|
||||||
|
|
||||||
if (rateLimit.keyBy === 'path' && context.path) {
|
if (rateLimit.keyBy === 'path' && context.path) {
|
||||||
key = `${context.clientIp}:${context.path}`;
|
key = `${context.clientIp}:${context.path}`;
|
||||||
} else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) {
|
} else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) {
|
||||||
@@ -180,15 +130,15 @@ export class SecurityManager {
|
|||||||
key = `${context.clientIp}:${headerValue}`;
|
key = `${context.clientIp}:${headerValue}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create rate limit tracking for this route
|
// Get or create rate limit tracking for this route
|
||||||
if (!this.rateLimits.has(routeId)) {
|
if (!this.rateLimits.has(routeId)) {
|
||||||
this.rateLimits.set(routeId, new Map());
|
this.rateLimits.set(routeId, new Map());
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeLimits = this.rateLimits.get(routeId)!;
|
const routeLimits = this.rateLimits.get(routeId)!;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Get or create rate limit tracking for this key
|
// Get or create rate limit tracking for this key
|
||||||
let limit = routeLimits.get(key);
|
let limit = routeLimits.get(key);
|
||||||
if (!limit || limit.expiry < now) {
|
if (!limit || limit.expiry < now) {
|
||||||
@@ -200,37 +150,30 @@ export class SecurityManager {
|
|||||||
routeLimits.set(key, limit);
|
routeLimits.set(key, limit);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment the counter
|
// Increment the counter
|
||||||
limit.count++;
|
limit.count++;
|
||||||
|
|
||||||
// Check if rate limit is exceeded
|
// Check if rate limit is exceeded
|
||||||
return limit.count <= rateLimit.maxRequests;
|
return limit.count <= rateLimit.maxRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up expired rate limits
|
* Clean up expired rate limits
|
||||||
* Should be called periodically to prevent memory leaks
|
* Should be called periodically to prevent memory leaks
|
||||||
*/
|
*/
|
||||||
public cleanupExpiredRateLimits(): void {
|
public cleanupExpiredRateLimits(): void {
|
||||||
const now = Date.now();
|
cleanupExpiredRateLimits(this.rateLimits, {
|
||||||
for (const [routeId, routeLimits] of this.rateLimits.entries()) {
|
info: this.logger.info.bind(this.logger),
|
||||||
let removed = 0;
|
warn: this.logger.warn.bind(this.logger),
|
||||||
for (const [key, limit] of routeLimits.entries()) {
|
error: this.logger.error.bind(this.logger),
|
||||||
if (limit.expiry < now) {
|
debug: this.logger.debug?.bind(this.logger)
|
||||||
routeLimits.delete(key);
|
});
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (removed > 0) {
|
|
||||||
this.logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check basic auth credentials
|
* Check basic auth credentials
|
||||||
*
|
*
|
||||||
* @param route The route to check auth for
|
* @param route The route to check auth for
|
||||||
* @param username The provided username
|
* @param username The provided username
|
||||||
* @param password The provided password
|
* @param password The provided password
|
||||||
@@ -240,22 +183,22 @@ export class SecurityManager {
|
|||||||
if (!route.security?.basicAuth?.enabled) {
|
if (!route.security?.basicAuth?.enabled) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const basicAuth = route.security.basicAuth;
|
const basicAuth = route.security.basicAuth;
|
||||||
|
|
||||||
// Check credentials against configured users
|
// Check credentials against configured users
|
||||||
for (const user of basicAuth.users) {
|
for (const user of basicAuth.users) {
|
||||||
if (user.username === username && user.password === password) {
|
if (user.username === username && user.password === password) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify a JWT token
|
* Verify a JWT token
|
||||||
*
|
*
|
||||||
* @param route The route to verify the token for
|
* @param route The route to verify the token for
|
||||||
* @param token The JWT token to verify
|
* @param token The JWT token to verify
|
||||||
* @returns True if the token is valid, false otherwise
|
* @returns True if the token is valid, false otherwise
|
||||||
@@ -264,38 +207,37 @@ export class SecurityManager {
|
|||||||
if (!route.security?.jwtAuth?.enabled) {
|
if (!route.security?.jwtAuth?.enabled) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// This is a simplified version - in production you'd use a proper JWT library
|
|
||||||
const jwtAuth = route.security.jwtAuth;
|
const jwtAuth = route.security.jwtAuth;
|
||||||
|
|
||||||
// Verify structure
|
// Verify structure
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode payload
|
// Decode payload
|
||||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check issuer
|
// Check issuer
|
||||||
if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) {
|
if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check audience
|
// Check audience
|
||||||
if (jwtAuth.audience && payload.aud !== jwtAuth.audience) {
|
if (jwtAuth.audience && payload.aud !== jwtAuth.audience) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a real implementation, you'd also verify the signature
|
// Note: In a real implementation, you'd also verify the signature
|
||||||
// using the secret and algorithm specified in jwtAuth
|
// using the secret and algorithm specified in jwtAuth
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error verifying JWT: ${err}`);
|
this.logger.error(`Error verifying JWT: ${err}`);
|
||||||
@@ -304,12 +246,20 @@ export class SecurityManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get connections count by IP
|
* Get connections count by IP (checks normalized variants)
|
||||||
*/
|
*/
|
||||||
public getConnectionCountByIP(ip: string): number {
|
public getConnectionCountByIP(ip: string): number {
|
||||||
return this.connectionsByIP.get(ip)?.size || 0;
|
// Check all normalized variants of the IP
|
||||||
|
const variants = normalizeIP(ip);
|
||||||
|
for (const variant of variants) {
|
||||||
|
const connections = this.connectionsByIP.get(variant);
|
||||||
|
if (connections) {
|
||||||
|
return connections.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check and update connection rate for an IP
|
* Check and update connection rate for an IP
|
||||||
* @returns true if within rate limit, false if exceeding limit
|
* @returns true if within rate limit, false if exceeding limit
|
||||||
@@ -318,43 +268,73 @@ export class SecurityManager {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const minute = 60 * 1000;
|
const minute = 60 * 1000;
|
||||||
|
|
||||||
if (!this.connectionRateByIP.has(ip)) {
|
// Find existing rate tracking (check normalized variants)
|
||||||
this.connectionRateByIP.set(ip, [now]);
|
const variants = normalizeIP(ip);
|
||||||
|
let existingKey: string | null = null;
|
||||||
|
for (const variant of variants) {
|
||||||
|
if (this.connectionRateByIP.has(variant)) {
|
||||||
|
existingKey = variant;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = existingKey || ip;
|
||||||
|
|
||||||
|
if (!this.connectionRateByIP.has(key)) {
|
||||||
|
this.connectionRateByIP.set(key, [now]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get timestamps and filter out entries older than 1 minute
|
// Get timestamps and filter out entries older than 1 minute
|
||||||
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
|
const timestamps = this.connectionRateByIP.get(key)!.filter((time) => now - time < minute);
|
||||||
timestamps.push(now);
|
timestamps.push(now);
|
||||||
this.connectionRateByIP.set(ip, timestamps);
|
this.connectionRateByIP.set(key, timestamps);
|
||||||
|
|
||||||
// Check if rate exceeds limit
|
// Check if rate exceeds limit
|
||||||
return timestamps.length <= this.connectionRateLimitPerMinute;
|
return timestamps.length <= this.connectionRateLimitPerMinute;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track connection by IP
|
* Track connection by IP
|
||||||
*/
|
*/
|
||||||
public trackConnectionByIP(ip: string, connectionId: string): void {
|
public trackConnectionByIP(ip: string, connectionId: string): void {
|
||||||
if (!this.connectionsByIP.has(ip)) {
|
// Check if any variant already exists
|
||||||
this.connectionsByIP.set(ip, new Set());
|
const variants = normalizeIP(ip);
|
||||||
|
let existingKey: string | null = null;
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
if (this.connectionsByIP.has(variant)) {
|
||||||
|
existingKey = variant;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.connectionsByIP.get(ip)!.add(connectionId);
|
|
||||||
|
const key = existingKey || ip;
|
||||||
|
if (!this.connectionsByIP.has(key)) {
|
||||||
|
this.connectionsByIP.set(key, new Set());
|
||||||
|
}
|
||||||
|
this.connectionsByIP.get(key)!.add(connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove connection tracking for an IP
|
* Remove connection tracking for an IP
|
||||||
*/
|
*/
|
||||||
public removeConnectionByIP(ip: string, connectionId: string): void {
|
public removeConnectionByIP(ip: string, connectionId: string): void {
|
||||||
if (this.connectionsByIP.has(ip)) {
|
// Check all variants to find where the connection is tracked
|
||||||
const connections = this.connectionsByIP.get(ip)!;
|
const variants = normalizeIP(ip);
|
||||||
connections.delete(connectionId);
|
|
||||||
if (connections.size === 0) {
|
for (const variant of variants) {
|
||||||
this.connectionsByIP.delete(ip);
|
if (this.connectionsByIP.has(variant)) {
|
||||||
|
const connections = this.connectionsByIP.get(variant)!;
|
||||||
|
connections.delete(connectionId);
|
||||||
|
if (connections.size === 0) {
|
||||||
|
this.connectionsByIP.delete(variant);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if IP should be allowed considering connection rate and max connections
|
* Check if IP should be allowed considering connection rate and max connections
|
||||||
* @returns Object with result and reason
|
* @returns Object with result and reason
|
||||||
@@ -375,10 +355,10 @@ export class SecurityManager {
|
|||||||
reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded`
|
reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all IP tracking data (for shutdown)
|
* Clears all IP tracking data (for shutdown)
|
||||||
*/
|
*/
|
||||||
@@ -386,7 +366,7 @@ export class SecurityManager {
|
|||||||
this.connectionsByIP.clear();
|
this.connectionsByIP.clear();
|
||||||
this.connectionRateByIP.clear();
|
this.connectionRateByIP.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start periodic cleanup of IP tracking data
|
* Start periodic cleanup of IP tracking data
|
||||||
*/
|
*/
|
||||||
@@ -396,7 +376,7 @@ export class SecurityManager {
|
|||||||
this.performIpCleanup();
|
this.performIpCleanup();
|
||||||
}, 60000).unref();
|
}, 60000).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform cleanup of expired IP data
|
* Perform cleanup of expired IP data
|
||||||
*/
|
*/
|
||||||
@@ -405,11 +385,11 @@ export class SecurityManager {
|
|||||||
const minute = 60 * 1000;
|
const minute = 60 * 1000;
|
||||||
let cleanedRateLimits = 0;
|
let cleanedRateLimits = 0;
|
||||||
let cleanedIPs = 0;
|
let cleanedIPs = 0;
|
||||||
|
|
||||||
// Clean up expired rate limit timestamps
|
// Clean up expired rate limit timestamps
|
||||||
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
|
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
|
||||||
const validTimestamps = timestamps.filter(time => now - time < minute);
|
const validTimestamps = timestamps.filter((time) => now - time < minute);
|
||||||
|
|
||||||
if (validTimestamps.length === 0) {
|
if (validTimestamps.length === 0) {
|
||||||
this.connectionRateByIP.delete(ip);
|
this.connectionRateByIP.delete(ip);
|
||||||
cleanedRateLimits++;
|
cleanedRateLimits++;
|
||||||
@@ -417,7 +397,7 @@ export class SecurityManager {
|
|||||||
this.connectionRateByIP.set(ip, validTimestamps);
|
this.connectionRateByIP.set(ip, validTimestamps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up IPs with no active connections
|
// Clean up IPs with no active connections
|
||||||
for (const [ip, connections] of this.connectionsByIP.entries()) {
|
for (const [ip, connections] of this.connectionsByIP.entries()) {
|
||||||
if (connections.size === 0) {
|
if (connections.size === 0) {
|
||||||
@@ -425,7 +405,7 @@ export class SecurityManager {
|
|||||||
cleanedIPs++;
|
cleanedIPs++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
|
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
|
||||||
this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`);
|
this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
*/
|
*/
|
||||||
export * from './nftables-proxy.js';
|
export * from './nftables-proxy.js';
|
||||||
export * from './models/index.js';
|
export * from './models/index.js';
|
||||||
|
export * from './utils/index.js';
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { promisify } from 'util';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { delay } from '../../core/utils/async-utils.js';
|
|
||||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||||
import {
|
import {
|
||||||
NftBaseError,
|
|
||||||
NftValidationError,
|
NftValidationError,
|
||||||
NftExecutionError,
|
NftExecutionError,
|
||||||
NftResourceError
|
NftResourceError
|
||||||
@@ -16,6 +14,12 @@ import type {
|
|||||||
NfTableProxyOptions,
|
NfTableProxyOptions,
|
||||||
NfTablesStatus
|
NfTablesStatus
|
||||||
} from './models/index.js';
|
} from './models/index.js';
|
||||||
|
import {
|
||||||
|
NftCommandExecutor,
|
||||||
|
normalizePortSpec,
|
||||||
|
validateSettings,
|
||||||
|
filterIPsByFamily
|
||||||
|
} from './utils/index.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -44,11 +48,12 @@ export class NfTablesProxy {
|
|||||||
private ruleTag: string;
|
private ruleTag: string;
|
||||||
private tableName: string;
|
private tableName: string;
|
||||||
private tempFilePath: string;
|
private tempFilePath: string;
|
||||||
|
private executor: NftCommandExecutor;
|
||||||
private static NFT_CMD = 'nft';
|
private static NFT_CMD = 'nft';
|
||||||
|
|
||||||
constructor(settings: NfTableProxyOptions) {
|
constructor(settings: NfTableProxyOptions) {
|
||||||
// Validate inputs to prevent command injection
|
// Validate inputs to prevent command injection
|
||||||
this.validateSettings(settings);
|
validateSettings(settings);
|
||||||
|
|
||||||
// Set default settings
|
// Set default settings
|
||||||
this.settings = {
|
this.settings = {
|
||||||
@@ -74,6 +79,16 @@ export class NfTablesProxy {
|
|||||||
// Create a temp file path for batch operations
|
// Create a temp file path for batch operations
|
||||||
this.tempFilePath = path.join(os.tmpdir(), `nft-rules-${Date.now()}.nft`);
|
this.tempFilePath = path.join(os.tmpdir(), `nft-rules-${Date.now()}.nft`);
|
||||||
|
|
||||||
|
// Create the command executor
|
||||||
|
this.executor = new NftCommandExecutor(
|
||||||
|
(level, message, data) => this.log(level, message, data),
|
||||||
|
{
|
||||||
|
maxRetries: this.settings.maxRetries,
|
||||||
|
retryDelayMs: this.settings.retryDelayMs,
|
||||||
|
tempFilePath: this.tempFilePath
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Register cleanup handlers if deleteOnExit is true
|
// Register cleanup handlers if deleteOnExit is true
|
||||||
if (this.settings.deleteOnExit) {
|
if (this.settings.deleteOnExit) {
|
||||||
// Synchronous cleanup for 'exit' event (only sync code runs here)
|
// Synchronous cleanup for 'exit' event (only sync code runs here)
|
||||||
@@ -104,183 +119,17 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates settings to prevent command injection and ensure valid values
|
|
||||||
*/
|
|
||||||
private validateSettings(settings: NfTableProxyOptions): void {
|
|
||||||
// Validate port numbers
|
|
||||||
const validatePorts = (port: number | PortRange | Array<number | PortRange>) => {
|
|
||||||
if (Array.isArray(port)) {
|
|
||||||
port.forEach(p => validatePorts(p));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof port === 'number') {
|
|
||||||
if (port < 1 || port > 65535) {
|
|
||||||
throw new NftValidationError(`Invalid port number: ${port}`);
|
|
||||||
}
|
|
||||||
} else if (typeof port === 'object') {
|
|
||||||
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
|
|
||||||
throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
validatePorts(settings.fromPort);
|
|
||||||
validatePorts(settings.toPort);
|
|
||||||
|
|
||||||
// Define regex patterns for validation
|
|
||||||
const ipRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
|
|
||||||
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
|
|
||||||
|
|
||||||
// Validate IP addresses
|
|
||||||
const validateIPs = (ips?: string[]) => {
|
|
||||||
if (!ips) return;
|
|
||||||
|
|
||||||
for (const ip of ips) {
|
|
||||||
if (!ipRegex.test(ip) && !ipv6Regex.test(ip)) {
|
|
||||||
throw new NftValidationError(`Invalid IP address format: ${ip}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
validateIPs(settings.ipAllowList);
|
|
||||||
validateIPs(settings.ipBlockList);
|
|
||||||
|
|
||||||
// Validate toHost - only allow hostnames or IPs
|
|
||||||
if (settings.toHost) {
|
|
||||||
const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
|
|
||||||
if (!hostRegex.test(settings.toHost) && !ipRegex.test(settings.toHost) && !ipv6Regex.test(settings.toHost)) {
|
|
||||||
throw new NftValidationError(`Invalid host format: ${settings.toHost}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate table name to prevent command injection
|
|
||||||
if (settings.tableName) {
|
|
||||||
const tableNameRegex = /^[a-zA-Z0-9_]+$/;
|
|
||||||
if (!tableNameRegex.test(settings.tableName)) {
|
|
||||||
throw new NftValidationError(`Invalid table name: ${settings.tableName}. Only alphanumeric characters and underscores are allowed.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate QoS settings if enabled
|
|
||||||
if (settings.qos?.enabled) {
|
|
||||||
if (settings.qos.maxRate) {
|
|
||||||
const rateRegex = /^[0-9]+[kKmMgG]?bps$/;
|
|
||||||
if (!rateRegex.test(settings.qos.maxRate)) {
|
|
||||||
throw new NftValidationError(`Invalid rate format: ${settings.qos.maxRate}. Use format like "10mbps", "1gbps", etc.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.qos.priority !== undefined) {
|
|
||||||
if (settings.qos.priority < 1 || settings.qos.priority > 10 || !Number.isInteger(settings.qos.priority)) {
|
|
||||||
throw new NftValidationError(`Invalid priority: ${settings.qos.priority}. Must be an integer between 1 and 10.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes port specifications into an array of port ranges
|
|
||||||
*/
|
|
||||||
private normalizePortSpec(portSpec: number | PortRange | Array<number | PortRange>): PortRange[] {
|
|
||||||
const result: PortRange[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(portSpec)) {
|
|
||||||
// If it's an array, process each element
|
|
||||||
for (const spec of portSpec) {
|
|
||||||
result.push(...this.normalizePortSpec(spec));
|
|
||||||
}
|
|
||||||
} else if (typeof portSpec === 'number') {
|
|
||||||
// Single port becomes a range with the same start and end
|
|
||||||
result.push({ from: portSpec, to: portSpec });
|
|
||||||
} else {
|
|
||||||
// Already a range
|
|
||||||
result.push(portSpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a command with retry capability
|
|
||||||
*/
|
|
||||||
private async executeWithRetry(command: string, maxRetries = 3, retryDelayMs = 1000): Promise<string> {
|
|
||||||
let lastError: Error | undefined;
|
|
||||||
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(command);
|
|
||||||
return stdout;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err;
|
|
||||||
this.log('warn', `Command failed (attempt ${i+1}/${maxRetries}): ${command}`, { error: err.message });
|
|
||||||
|
|
||||||
// Wait before retry, unless it's the last attempt
|
|
||||||
if (i < maxRetries - 1) {
|
|
||||||
await delay(retryDelayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute system command synchronously (single attempt, no retry)
|
|
||||||
* Used only for exit handlers where the process is terminating anyway.
|
|
||||||
* For normal operations, use the async executeWithRetry method.
|
|
||||||
*/
|
|
||||||
private executeSync(command: string): string {
|
|
||||||
try {
|
|
||||||
return execSync(command, { timeout: 5000 }).toString();
|
|
||||||
} catch (err) {
|
|
||||||
this.log('warn', `Sync command failed: ${command}`, { error: err.message });
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute nftables commands with a temporary file
|
|
||||||
* This helper handles the common pattern of writing rules to a temp file,
|
|
||||||
* executing nftables with the file, and cleaning up
|
|
||||||
*/
|
|
||||||
private async executeWithTempFile(rulesetContent: string): Promise<void> {
|
|
||||||
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.executeWithRetry(
|
|
||||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
|
||||||
this.settings.maxRetries,
|
|
||||||
this.settings.retryDelayMs
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// Always clean up the temp file
|
|
||||||
await AsyncFileSystem.remove(this.tempFilePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if nftables is available and the required modules are loaded
|
* Checks if nftables is available and the required modules are loaded
|
||||||
*/
|
*/
|
||||||
private async checkNftablesAvailability(): Promise<boolean> {
|
private async checkNftablesAvailability(): Promise<boolean> {
|
||||||
try {
|
const available = await this.executor.checkAvailability();
|
||||||
await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} --version`, this.settings.maxRetries, this.settings.retryDelayMs);
|
|
||||||
|
if (available && this.settings.useAdvancedNAT) {
|
||||||
// Check for conntrack support if we're using advanced NAT
|
await this.executor.checkConntrackModules();
|
||||||
if (this.settings.useAdvancedNAT) {
|
|
||||||
try {
|
|
||||||
await this.executeWithRetry('lsmod | grep nf_conntrack', this.settings.maxRetries, this.settings.retryDelayMs);
|
|
||||||
} catch (err) {
|
|
||||||
this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
this.log('error', `nftables is not available: ${err.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -291,7 +140,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if the table already exists
|
// Check if the table already exists
|
||||||
const stdout = await this.executeWithRetry(
|
const stdout = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -301,7 +150,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
if (!tableExists) {
|
if (!tableExists) {
|
||||||
// Create the table
|
// Create the table
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add table ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} add table ${family} ${this.tableName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -310,7 +159,7 @@ export class NfTablesProxy {
|
|||||||
this.log('info', `Created table ${family} ${this.tableName}`);
|
this.log('info', `Created table ${family} ${this.tableName}`);
|
||||||
|
|
||||||
// Create the nat chain for the prerouting hook
|
// Create the nat chain for the prerouting hook
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_prerouting { type nat hook prerouting priority -100 ; }`,
|
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_prerouting { type nat hook prerouting priority -100 ; }`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -320,7 +169,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
// Create the nat chain for the postrouting hook if not preserving source IP
|
// Create the nat chain for the postrouting hook if not preserving source IP
|
||||||
if (!this.settings.preserveSourceIP) {
|
if (!this.settings.preserveSourceIP) {
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_postrouting { type nat hook postrouting priority 100 ; }`,
|
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_postrouting { type nat hook postrouting priority 100 ; }`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -331,7 +180,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
// Create the chain for NetworkProxy integration if needed
|
// Create the chain for NetworkProxy integration if needed
|
||||||
if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) {
|
if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) {
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_output { type nat hook output priority 0 ; }`,
|
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_output { type nat hook output priority 0 ; }`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -342,7 +191,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
// Create the QoS chain if needed
|
// Create the QoS chain if needed
|
||||||
if (this.settings.qos?.enabled) {
|
if (this.settings.qos?.enabled) {
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} qos_forward { type filter hook forward priority 0 ; }`,
|
`${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} qos_forward { type filter hook forward priority 0 ; }`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -372,11 +221,7 @@ export class NfTablesProxy {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Filter IPs based on family
|
// Filter IPs based on family
|
||||||
const filteredIPs = ips.filter(ip => {
|
const filteredIPs = filterIPsByFamily(ips, family as 'ip' | 'ip6');
|
||||||
if (family === 'ip6' && ip.includes(':')) return true;
|
|
||||||
if (family === 'ip' && ip.includes('.')) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filteredIPs.length === 0) {
|
if (filteredIPs.length === 0) {
|
||||||
this.log('info', `No IP addresses of type ${setType} to add to set ${setName}`);
|
this.log('info', `No IP addresses of type ${setType} to add to set ${setName}`);
|
||||||
@@ -385,7 +230,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
// Check if set already exists
|
// Check if set already exists
|
||||||
try {
|
try {
|
||||||
const sets = await this.executeWithRetry(
|
const sets = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -395,7 +240,7 @@ export class NfTablesProxy {
|
|||||||
this.log('info', `IP set ${setName} already exists, will add elements`);
|
this.log('info', `IP set ${setName} already exists, will add elements`);
|
||||||
} else {
|
} else {
|
||||||
// Create the set
|
// Create the set
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
|
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -405,7 +250,7 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Set might not exist yet, create it
|
// Set might not exist yet, create it
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
|
`${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -420,7 +265,7 @@ export class NfTablesProxy {
|
|||||||
const batch = filteredIPs.slice(i, i + batchSize);
|
const batch = filteredIPs.slice(i, i + batchSize);
|
||||||
const elements = batch.join(', ');
|
const elements = batch.join(', ');
|
||||||
|
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} add element ${family} ${this.tableName} ${setName} { ${elements} }`,
|
`${NfTablesProxy.NFT_CMD} add element ${family} ${this.tableName} ${setName} { ${elements} }`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -563,7 +408,7 @@ export class NfTablesProxy {
|
|||||||
// Only write and apply if we have rules to add
|
// Only write and apply if we have rules to add
|
||||||
if (rulesetContent) {
|
if (rulesetContent) {
|
||||||
// Apply the ruleset using the helper
|
// Apply the ruleset using the helper
|
||||||
await this.executeWithTempFile(rulesetContent);
|
await this.executor.executeWithTempFile(rulesetContent);
|
||||||
|
|
||||||
this.log('info', `Added source IP filter rules for ${family}`);
|
this.log('info', `Added source IP filter rules for ${family}`);
|
||||||
|
|
||||||
@@ -593,7 +438,7 @@ export class NfTablesProxy {
|
|||||||
* Gets a comma-separated list of all ports from a port specification
|
* Gets a comma-separated list of all ports from a port specification
|
||||||
*/
|
*/
|
||||||
private getAllPorts(portSpec: number | PortRange | Array<number | PortRange>): string {
|
private getAllPorts(portSpec: number | PortRange | Array<number | PortRange>): string {
|
||||||
const portRanges = this.normalizePortSpec(portSpec);
|
const portRanges = normalizePortSpec(portSpec);
|
||||||
const ports: string[] = [];
|
const ports: string[] = [];
|
||||||
|
|
||||||
for (const range of portRanges) {
|
for (const range of portRanges) {
|
||||||
@@ -620,8 +465,8 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the port ranges
|
// Get the port ranges
|
||||||
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
|
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
|
||||||
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
|
const toPortRanges = normalizePortSpec(this.settings.toPort);
|
||||||
|
|
||||||
let rulesetContent = '';
|
let rulesetContent = '';
|
||||||
|
|
||||||
@@ -670,7 +515,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
// Apply the rules if we have any
|
// Apply the rules if we have any
|
||||||
if (rulesetContent) {
|
if (rulesetContent) {
|
||||||
await this.executeWithTempFile(rulesetContent);
|
await this.executor.executeWithTempFile(rulesetContent);
|
||||||
|
|
||||||
this.log('info', `Added advanced NAT rules for ${family}`);
|
this.log('info', `Added advanced NAT rules for ${family}`);
|
||||||
|
|
||||||
@@ -708,8 +553,8 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Normalize port specifications
|
// Normalize port specifications
|
||||||
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
|
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
|
||||||
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
|
const toPortRanges = normalizePortSpec(this.settings.toPort);
|
||||||
|
|
||||||
// Handle the case where fromPort and toPort counts don't match
|
// Handle the case where fromPort and toPort counts don't match
|
||||||
if (fromPortRanges.length !== toPortRanges.length) {
|
if (fromPortRanges.length !== toPortRanges.length) {
|
||||||
@@ -815,7 +660,7 @@ export class NfTablesProxy {
|
|||||||
// Apply the ruleset if we have any rules
|
// Apply the ruleset if we have any rules
|
||||||
if (rulesetContent) {
|
if (rulesetContent) {
|
||||||
// Apply the ruleset using the helper
|
// Apply the ruleset using the helper
|
||||||
await this.executeWithTempFile(rulesetContent);
|
await this.executor.executeWithTempFile(rulesetContent);
|
||||||
|
|
||||||
this.log('info', `Added port forwarding rules for ${family}`);
|
this.log('info', `Added port forwarding rules for ${family}`);
|
||||||
|
|
||||||
@@ -919,7 +764,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
// Apply the ruleset if we have any rules
|
// Apply the ruleset if we have any rules
|
||||||
if (rulesetContent) {
|
if (rulesetContent) {
|
||||||
await this.executeWithTempFile(rulesetContent);
|
await this.executor.executeWithTempFile(rulesetContent);
|
||||||
|
|
||||||
this.log('info', `Added port forwarding rules for ${family}`);
|
this.log('info', `Added port forwarding rules for ${family}`);
|
||||||
|
|
||||||
@@ -972,7 +817,7 @@ export class NfTablesProxy {
|
|||||||
// Add priority marking if specified
|
// Add priority marking if specified
|
||||||
if (this.settings.qos.priority !== undefined) {
|
if (this.settings.qos.priority !== undefined) {
|
||||||
// Check if the chain exists
|
// Check if the chain exists
|
||||||
const chainsOutput = await this.executeWithRetry(
|
const chainsOutput = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list chains ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} list chains ${family} ${this.tableName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -988,7 +833,7 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add the rules to mark packets with this priority
|
// Add the rules to mark packets with this priority
|
||||||
for (const range of this.normalizePortSpec(this.settings.toPort)) {
|
for (const range of normalizePortSpec(this.settings.toPort)) {
|
||||||
const markRule = `add rule ${family} ${this.tableName} ${qosChain} ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`;
|
const markRule = `add rule ${family} ${this.tableName} ${qosChain} ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`;
|
||||||
rulesetContent += `${markRule}\n`;
|
rulesetContent += `${markRule}\n`;
|
||||||
|
|
||||||
@@ -1005,7 +850,7 @@ export class NfTablesProxy {
|
|||||||
// Apply the ruleset if we have any rules
|
// Apply the ruleset if we have any rules
|
||||||
if (rulesetContent) {
|
if (rulesetContent) {
|
||||||
// Apply the ruleset using the helper
|
// Apply the ruleset using the helper
|
||||||
await this.executeWithTempFile(rulesetContent);
|
await this.executor.executeWithTempFile(rulesetContent);
|
||||||
|
|
||||||
this.log('info', `Added QoS rules for ${family}`);
|
this.log('info', `Added QoS rules for ${family}`);
|
||||||
|
|
||||||
@@ -1048,7 +893,7 @@ export class NfTablesProxy {
|
|||||||
const rule = `add rule ${family} ${this.tableName} ${outputChain} ${this.settings.protocol} daddr ${localhost} redirect to :${netProxyConfig.sslTerminationPort} comment "${this.ruleTag}:NETPROXY_REDIRECT"`;
|
const rule = `add rule ${family} ${this.tableName} ${outputChain} ${this.settings.protocol} daddr ${localhost} redirect to :${netProxyConfig.sslTerminationPort} comment "${this.ruleTag}:NETPROXY_REDIRECT"`;
|
||||||
|
|
||||||
// Apply the rule
|
// Apply the rule
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} ${rule}`,
|
`${NfTablesProxy.NFT_CMD} ${rule}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1091,7 +936,7 @@ export class NfTablesProxy {
|
|||||||
const commentTag = commentMatch[1];
|
const commentTag = commentMatch[1];
|
||||||
|
|
||||||
// List the chain to check if our rule is there
|
// List the chain to check if our rule is there
|
||||||
const stdout = await this.executeWithRetry(
|
const stdout = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list chain ${tableFamily} ${tableName} ${chainName}`,
|
`${NfTablesProxy.NFT_CMD} list chain ${tableFamily} ${tableName} ${chainName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1127,7 +972,7 @@ export class NfTablesProxy {
|
|||||||
try {
|
try {
|
||||||
// For nftables, create a delete rule by replacing 'add' with 'delete'
|
// For nftables, create a delete rule by replacing 'add' with 'delete'
|
||||||
const deleteRule = rule.ruleContents.replace('add rule', 'delete rule');
|
const deleteRule = rule.ruleContents.replace('add rule', 'delete rule');
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} ${deleteRule}`,
|
`${NfTablesProxy.NFT_CMD} ${deleteRule}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1149,7 +994,7 @@ export class NfTablesProxy {
|
|||||||
*/
|
*/
|
||||||
private async tableExists(family: string, tableName: string): Promise<boolean> {
|
private async tableExists(family: string, tableName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const stdout = await this.executeWithRetry(
|
const stdout = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1178,7 +1023,7 @@ export class NfTablesProxy {
|
|||||||
try {
|
try {
|
||||||
// Try to get connection metrics if conntrack is available
|
// Try to get connection metrics if conntrack is available
|
||||||
try {
|
try {
|
||||||
const stdout = await this.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs);
|
const stdout = await this.executor.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs);
|
||||||
metrics.activeConnections = parseInt(stdout.trim(), 10);
|
metrics.activeConnections = parseInt(stdout.trim(), 10);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// conntrack not available, skip this metric
|
// conntrack not available, skip this metric
|
||||||
@@ -1187,7 +1032,7 @@ export class NfTablesProxy {
|
|||||||
// Try to get forwarded connections count from nftables counters
|
// Try to get forwarded connections count from nftables counters
|
||||||
try {
|
try {
|
||||||
// Look for counters in our rules
|
// Look for counters in our rules
|
||||||
const stdout = await this.executeWithRetry(
|
const stdout = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list table ip ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} list table ip ${this.tableName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1238,7 +1083,7 @@ export class NfTablesProxy {
|
|||||||
try {
|
try {
|
||||||
for (const family of ['ip', 'ip6']) {
|
for (const family of ['ip', 'ip6']) {
|
||||||
try {
|
try {
|
||||||
const stdout = await this.executeWithRetry(
|
const stdout = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1290,7 +1135,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get list of configured tables
|
// Get list of configured tables
|
||||||
const stdout = await this.executeWithRetry(
|
const stdout = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list tables`,
|
`${NfTablesProxy.NFT_CMD} list tables`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1396,8 +1241,8 @@ export class NfTablesProxy {
|
|||||||
// Port forwarding rules
|
// Port forwarding rules
|
||||||
if (this.settings.useAdvancedNAT) {
|
if (this.settings.useAdvancedNAT) {
|
||||||
// Advanced NAT with connection tracking
|
// Advanced NAT with connection tracking
|
||||||
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
|
const fromPortRanges = normalizePortSpec(this.settings.fromPort);
|
||||||
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
|
const toPortRanges = normalizePortSpec(this.settings.toPort);
|
||||||
|
|
||||||
if (fromPortRanges.length === 1 && toPortRanges.length === 1) {
|
if (fromPortRanges.length === 1 && toPortRanges.length === 1) {
|
||||||
const fromRange = fromPortRanges[0];
|
const fromRange = fromPortRanges[0];
|
||||||
@@ -1413,8 +1258,8 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Standard NAT rules
|
// Standard NAT rules
|
||||||
const fromRanges = this.normalizePortSpec(this.settings.fromPort);
|
const fromRanges = normalizePortSpec(this.settings.fromPort);
|
||||||
const toRanges = this.normalizePortSpec(this.settings.toPort);
|
const toRanges = normalizePortSpec(this.settings.toPort);
|
||||||
|
|
||||||
if (fromRanges.length === 1 && toRanges.length === 1) {
|
if (fromRanges.length === 1 && toRanges.length === 1) {
|
||||||
const fromRange = fromRanges[0];
|
const fromRange = fromRanges[0];
|
||||||
@@ -1460,7 +1305,7 @@ export class NfTablesProxy {
|
|||||||
if (this.settings.qos.priority !== undefined) {
|
if (this.settings.qos.priority !== undefined) {
|
||||||
commands.push(`add chain ip ${this.tableName} prio${this.settings.qos.priority} { type filter hook forward priority ${this.settings.qos.priority * 10}; }`);
|
commands.push(`add chain ip ${this.tableName} prio${this.settings.qos.priority} { type filter hook forward priority ${this.settings.qos.priority * 10}; }`);
|
||||||
|
|
||||||
for (const range of this.normalizePortSpec(this.settings.toPort)) {
|
for (const range of normalizePortSpec(this.settings.toPort)) {
|
||||||
commands.push(`add rule ip ${this.tableName} qos_forward ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`);
|
commands.push(`add rule ip ${this.tableName} qos_forward ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1586,7 +1431,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Apply the ruleset
|
// Apply the ruleset
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1611,7 +1456,7 @@ export class NfTablesProxy {
|
|||||||
const [family, setName] = key.split(':');
|
const [family, setName] = key.split(':');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`,
|
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1661,7 +1506,7 @@ export class NfTablesProxy {
|
|||||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||||
|
|
||||||
// Apply the ruleset (single attempt, no retry - process is exiting)
|
// Apply the ruleset (single attempt, no retry - process is exiting)
|
||||||
this.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
|
this.executor.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
|
||||||
|
|
||||||
this.log('info', 'Removed all added rules');
|
this.log('info', 'Removed all added rules');
|
||||||
|
|
||||||
@@ -1685,7 +1530,7 @@ export class NfTablesProxy {
|
|||||||
const [family, setName] = key.split(':');
|
const [family, setName] = key.split(':');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.executeSync(
|
this.executor.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`
|
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1722,7 +1567,7 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the table has any rules
|
// Check if the table has any rules
|
||||||
const stdout = await this.executeWithRetry(
|
const stdout = await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1732,7 +1577,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
if (!hasRules) {
|
if (!hasRules) {
|
||||||
// Table is empty, delete it
|
// Table is empty, delete it
|
||||||
await this.executeWithRetry(
|
await this.executor.executeWithRetry(
|
||||||
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`,
|
||||||
this.settings.maxRetries,
|
this.settings.maxRetries,
|
||||||
this.settings.retryDelayMs
|
this.settings.retryDelayMs
|
||||||
@@ -1759,7 +1604,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if table exists
|
// Check if table exists
|
||||||
const tableExistsOutput = this.executeSync(
|
const tableExistsOutput = this.executor.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`
|
`${NfTablesProxy.NFT_CMD} list tables ${family}`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1770,7 +1615,7 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the table has any rules
|
// Check if the table has any rules
|
||||||
const stdout = this.executeSync(
|
const stdout = this.executor.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`
|
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1778,7 +1623,7 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
if (!hasRules) {
|
if (!hasRules) {
|
||||||
// Table is empty, delete it
|
// Table is empty, delete it
|
||||||
this.executeSync(
|
this.executor.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`
|
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
38
ts/proxies/nftables-proxy/utils/index.ts
Normal file
38
ts/proxies/nftables-proxy/utils/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* NFTables Proxy Utilities
|
||||||
|
*
|
||||||
|
* This module exports utility functions and classes for NFTables operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Command execution
|
||||||
|
export { NftCommandExecutor } from './nft-command-executor.js';
|
||||||
|
export type { INftLoggerFn, INftExecutorOptions } from './nft-command-executor.js';
|
||||||
|
|
||||||
|
// Port specification normalization
|
||||||
|
export {
|
||||||
|
normalizePortSpec,
|
||||||
|
validatePorts,
|
||||||
|
formatPortRange,
|
||||||
|
portSpecToNftExpr,
|
||||||
|
rangesOverlap,
|
||||||
|
mergeOverlappingRanges,
|
||||||
|
countPorts,
|
||||||
|
isPortInSpec
|
||||||
|
} from './nft-port-spec-normalizer.js';
|
||||||
|
|
||||||
|
// Rule validation
|
||||||
|
export {
|
||||||
|
isValidIP,
|
||||||
|
isValidIPv4,
|
||||||
|
isValidIPv6,
|
||||||
|
isValidHostname,
|
||||||
|
isValidTableName,
|
||||||
|
isValidRate,
|
||||||
|
validateIPs,
|
||||||
|
validateHost,
|
||||||
|
validateTableName,
|
||||||
|
validateQosSettings,
|
||||||
|
validateSettings,
|
||||||
|
isIPForFamily,
|
||||||
|
filterIPsByFamily
|
||||||
|
} from './nft-rule-validator.js';
|
||||||
162
ts/proxies/nftables-proxy/utils/nft-command-executor.ts
Normal file
162
ts/proxies/nftables-proxy/utils/nft-command-executor.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* NFTables Command Executor
|
||||||
|
*
|
||||||
|
* Handles command execution with retry logic, temp file management,
|
||||||
|
* and error handling for nftables operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec, execSync } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { delay } from '../../../core/utils/async-utils.js';
|
||||||
|
import { AsyncFileSystem } from '../../../core/utils/fs-utils.js';
|
||||||
|
import { NftExecutionError } from '../models/index.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export interface INftLoggerFn {
|
||||||
|
(level: 'info' | 'warn' | 'error' | 'debug', message: string, data?: Record<string, any>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INftExecutorOptions {
|
||||||
|
maxRetries?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
tempFilePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NFTables command executor with retry logic and temp file support
|
||||||
|
*/
|
||||||
|
export class NftCommandExecutor {
|
||||||
|
private static readonly NFT_CMD = 'nft';
|
||||||
|
private maxRetries: number;
|
||||||
|
private retryDelayMs: number;
|
||||||
|
private tempFilePath: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private log: INftLoggerFn,
|
||||||
|
options: INftExecutorOptions = {}
|
||||||
|
) {
|
||||||
|
this.maxRetries = options.maxRetries || 3;
|
||||||
|
this.retryDelayMs = options.retryDelayMs || 1000;
|
||||||
|
this.tempFilePath = options.tempFilePath || `/tmp/nft-rules-${Date.now()}.nft`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a command with retry capability
|
||||||
|
*/
|
||||||
|
async executeWithRetry(command: string, maxRetries?: number, retryDelayMs?: number): Promise<string> {
|
||||||
|
const retries = maxRetries ?? this.maxRetries;
|
||||||
|
const delayMs = retryDelayMs ?? this.retryDelayMs;
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(command);
|
||||||
|
return stdout;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err as Error;
|
||||||
|
this.log('warn', `Command failed (attempt ${i+1}/${retries}): ${command}`, { error: lastError.message });
|
||||||
|
|
||||||
|
// Wait before retry, unless it's the last attempt
|
||||||
|
if (i < retries - 1) {
|
||||||
|
await delay(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NftExecutionError(`Failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute system command synchronously (single attempt, no retry)
|
||||||
|
* Used only for exit handlers where the process is terminating anyway.
|
||||||
|
*/
|
||||||
|
executeSync(command: string): string {
|
||||||
|
try {
|
||||||
|
return execSync(command, { timeout: 5000 }).toString();
|
||||||
|
} catch (err) {
|
||||||
|
this.log('warn', `Sync command failed: ${command}`, { error: (err as Error).message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute nftables commands with a temporary file
|
||||||
|
*/
|
||||||
|
async executeWithTempFile(rulesetContent: string): Promise<void> {
|
||||||
|
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.executeWithRetry(
|
||||||
|
`${NftCommandExecutor.NFT_CMD} -f ${this.tempFilePath}`,
|
||||||
|
this.maxRetries,
|
||||||
|
this.retryDelayMs
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Always clean up the temp file
|
||||||
|
await AsyncFileSystem.remove(this.tempFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if nftables is available
|
||||||
|
*/
|
||||||
|
async checkAvailability(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} --version`, this.maxRetries, this.retryDelayMs);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `nftables is not available: ${(err as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connection tracking modules are loaded
|
||||||
|
*/
|
||||||
|
async checkConntrackModules(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.executeWithRetry('lsmod | grep nf_conntrack', this.maxRetries, this.retryDelayMs);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an nft command directly
|
||||||
|
*/
|
||||||
|
async nft(args: string): Promise<string> {
|
||||||
|
return this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} ${args}`, this.maxRetries, this.retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an nft command synchronously (for cleanup on exit)
|
||||||
|
*/
|
||||||
|
nftSync(args: string): string {
|
||||||
|
return this.executeSync(`${NftCommandExecutor.NFT_CMD} ${args}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the NFT command path
|
||||||
|
*/
|
||||||
|
static get nftCmd(): string {
|
||||||
|
return NftCommandExecutor.NFT_CMD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the temp file path
|
||||||
|
*/
|
||||||
|
setTempFilePath(path: string): void {
|
||||||
|
this.tempFilePath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update retry settings
|
||||||
|
*/
|
||||||
|
setRetryOptions(maxRetries: number, retryDelayMs: number): void {
|
||||||
|
this.maxRetries = maxRetries;
|
||||||
|
this.retryDelayMs = retryDelayMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts
Normal file
125
ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* NFTables Port Specification Normalizer
|
||||||
|
*
|
||||||
|
* Handles normalization and validation of port specifications
|
||||||
|
* for nftables rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PortRange } from '../models/index.js';
|
||||||
|
import { NftValidationError } from '../models/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes port specifications into an array of port ranges
|
||||||
|
*/
|
||||||
|
export function normalizePortSpec(portSpec: number | PortRange | Array<number | PortRange>): PortRange[] {
|
||||||
|
const result: PortRange[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(portSpec)) {
|
||||||
|
// If it's an array, process each element
|
||||||
|
for (const spec of portSpec) {
|
||||||
|
result.push(...normalizePortSpec(spec));
|
||||||
|
}
|
||||||
|
} else if (typeof portSpec === 'number') {
|
||||||
|
// Single port becomes a range with the same start and end
|
||||||
|
result.push({ from: portSpec, to: portSpec });
|
||||||
|
} else {
|
||||||
|
// Already a range
|
||||||
|
result.push(portSpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates port numbers or ranges
|
||||||
|
*/
|
||||||
|
export function validatePorts(port: number | PortRange | Array<number | PortRange>): void {
|
||||||
|
if (Array.isArray(port)) {
|
||||||
|
port.forEach(p => validatePorts(p));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof port === 'number') {
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
throw new NftValidationError(`Invalid port number: ${port}`);
|
||||||
|
}
|
||||||
|
} else if (typeof port === 'object') {
|
||||||
|
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
|
||||||
|
throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format port range for nftables rule
|
||||||
|
*/
|
||||||
|
export function formatPortRange(range: PortRange): string {
|
||||||
|
if (range.from === range.to) {
|
||||||
|
return String(range.from);
|
||||||
|
}
|
||||||
|
return `${range.from}-${range.to}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert port spec to nftables expression
|
||||||
|
*/
|
||||||
|
export function portSpecToNftExpr(portSpec: number | PortRange | Array<number | PortRange>): string {
|
||||||
|
const ranges = normalizePortSpec(portSpec);
|
||||||
|
|
||||||
|
if (ranges.length === 1) {
|
||||||
|
return formatPortRange(ranges[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple ports/ranges need to use a set
|
||||||
|
const ports = ranges.map(formatPortRange);
|
||||||
|
return `{ ${ports.join(', ')} }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two port ranges overlap
|
||||||
|
*/
|
||||||
|
export function rangesOverlap(range1: PortRange, range2: PortRange): boolean {
|
||||||
|
return range1.from <= range2.to && range2.from <= range1.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge overlapping port ranges
|
||||||
|
*/
|
||||||
|
export function mergeOverlappingRanges(ranges: PortRange[]): PortRange[] {
|
||||||
|
if (ranges.length <= 1) return ranges;
|
||||||
|
|
||||||
|
// Sort by start port
|
||||||
|
const sorted = [...ranges].sort((a, b) => a.from - b.from);
|
||||||
|
const merged: PortRange[] = [sorted[0]];
|
||||||
|
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const current = sorted[i];
|
||||||
|
const lastMerged = merged[merged.length - 1];
|
||||||
|
|
||||||
|
if (current.from <= lastMerged.to + 1) {
|
||||||
|
// Ranges overlap or are adjacent, merge them
|
||||||
|
lastMerged.to = Math.max(lastMerged.to, current.to);
|
||||||
|
} else {
|
||||||
|
// No overlap, add as new range
|
||||||
|
merged.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the total number of ports in a port specification
|
||||||
|
*/
|
||||||
|
export function countPorts(portSpec: number | PortRange | Array<number | PortRange>): number {
|
||||||
|
const ranges = normalizePortSpec(portSpec);
|
||||||
|
return ranges.reduce((total, range) => total + (range.to - range.from + 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a port is within the given specification
|
||||||
|
*/
|
||||||
|
export function isPortInSpec(port: number, portSpec: number | PortRange | Array<number | PortRange>): boolean {
|
||||||
|
const ranges = normalizePortSpec(portSpec);
|
||||||
|
return ranges.some(range => port >= range.from && port <= range.to);
|
||||||
|
}
|
||||||
156
ts/proxies/nftables-proxy/utils/nft-rule-validator.ts
Normal file
156
ts/proxies/nftables-proxy/utils/nft-rule-validator.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* NFTables Rule Validator
|
||||||
|
*
|
||||||
|
* Handles validation of settings and inputs for nftables operations.
|
||||||
|
* Prevents command injection and ensures valid values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PortRange, NfTableProxyOptions } from '../models/index.js';
|
||||||
|
import { NftValidationError } from '../models/index.js';
|
||||||
|
import { validatePorts } from './nft-port-spec-normalizer.js';
|
||||||
|
|
||||||
|
// IP address validation patterns
|
||||||
|
const IPV4_REGEX = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
|
||||||
|
const IPV6_REGEX = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
|
||||||
|
const HOSTNAME_REGEX = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
|
||||||
|
const TABLE_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||||
|
const RATE_REGEX = /^[0-9]+[kKmMgG]?bps$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an IP address (IPv4 or IPv6)
|
||||||
|
*/
|
||||||
|
export function isValidIP(ip: string): boolean {
|
||||||
|
return IPV4_REGEX.test(ip) || IPV6_REGEX.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an IPv4 address
|
||||||
|
*/
|
||||||
|
export function isValidIPv4(ip: string): boolean {
|
||||||
|
return IPV4_REGEX.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an IPv6 address
|
||||||
|
*/
|
||||||
|
export function isValidIPv6(ip: string): boolean {
|
||||||
|
return IPV6_REGEX.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a hostname
|
||||||
|
*/
|
||||||
|
export function isValidHostname(hostname: string): boolean {
|
||||||
|
return HOSTNAME_REGEX.test(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a table name for nftables
|
||||||
|
*/
|
||||||
|
export function isValidTableName(tableName: string): boolean {
|
||||||
|
return TABLE_NAME_REGEX.test(tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a rate specification (e.g., "10mbps")
|
||||||
|
*/
|
||||||
|
export function isValidRate(rate: string): boolean {
|
||||||
|
return RATE_REGEX.test(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an array of IP addresses
|
||||||
|
*/
|
||||||
|
export function validateIPs(ips?: string[]): void {
|
||||||
|
if (!ips) return;
|
||||||
|
|
||||||
|
for (const ip of ips) {
|
||||||
|
if (!isValidIP(ip)) {
|
||||||
|
throw new NftValidationError(`Invalid IP address format: ${ip}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a host (can be hostname or IP)
|
||||||
|
*/
|
||||||
|
export function validateHost(host?: string): void {
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
if (!isValidHostname(host) && !isValidIP(host)) {
|
||||||
|
throw new NftValidationError(`Invalid host format: ${host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a table name
|
||||||
|
*/
|
||||||
|
export function validateTableName(tableName?: string): void {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
if (!isValidTableName(tableName)) {
|
||||||
|
throw new NftValidationError(
|
||||||
|
`Invalid table name: ${tableName}. Only alphanumeric characters and underscores are allowed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates QoS settings
|
||||||
|
*/
|
||||||
|
export function validateQosSettings(qos?: NfTableProxyOptions['qos']): void {
|
||||||
|
if (!qos?.enabled) return;
|
||||||
|
|
||||||
|
if (qos.maxRate && !isValidRate(qos.maxRate)) {
|
||||||
|
throw new NftValidationError(
|
||||||
|
`Invalid rate format: ${qos.maxRate}. Use format like "10mbps", "1gbps", etc.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qos.priority !== undefined) {
|
||||||
|
if (qos.priority < 1 || qos.priority > 10 || !Number.isInteger(qos.priority)) {
|
||||||
|
throw new NftValidationError(
|
||||||
|
`Invalid priority: ${qos.priority}. Must be an integer between 1 and 10.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates all NfTablesProxy settings
|
||||||
|
*/
|
||||||
|
export function validateSettings(settings: NfTableProxyOptions): void {
|
||||||
|
// Validate port numbers
|
||||||
|
validatePorts(settings.fromPort);
|
||||||
|
validatePorts(settings.toPort);
|
||||||
|
|
||||||
|
// Validate IP addresses
|
||||||
|
validateIPs(settings.ipAllowList);
|
||||||
|
validateIPs(settings.ipBlockList);
|
||||||
|
|
||||||
|
// Validate target host
|
||||||
|
validateHost(settings.toHost);
|
||||||
|
|
||||||
|
// Validate table name
|
||||||
|
validateTableName(settings.tableName);
|
||||||
|
|
||||||
|
// Validate QoS settings
|
||||||
|
validateQosSettings(settings.qos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP matches the given family (ip or ip6)
|
||||||
|
*/
|
||||||
|
export function isIPForFamily(ip: string, family: 'ip' | 'ip6'): boolean {
|
||||||
|
if (family === 'ip6') {
|
||||||
|
return ip.includes(':');
|
||||||
|
}
|
||||||
|
return ip.includes('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter IPs by family
|
||||||
|
*/
|
||||||
|
export function filterIPsByFamily(ips: string[], family: 'ip' | 'ip6'): string[] {
|
||||||
|
return ips.filter(ip => isIPForFamily(ip, family));
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { SmartProxy } from './smart-proxy.js';
|
import type { SmartProxy } from './smart-proxy.js';
|
||||||
import { logger } from '../../core/utils/logger.js';
|
|
||||||
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
||||||
|
import { isIPAuthorized, normalizeIP } from '../../core/utils/security-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles security aspects like IP tracking, rate limiting, and authorization
|
* Handles security aspects like IP tracking, rate limiting, and authorization
|
||||||
|
* for SmartProxy. This is a lightweight wrapper that uses shared utilities.
|
||||||
*/
|
*/
|
||||||
export class SecurityManager {
|
export class SecurityManager {
|
||||||
private connectionsByIP: Map<string, Set<string>> = new Map();
|
private connectionsByIP: Map<string, Set<string>> = new Map();
|
||||||
@@ -15,14 +16,22 @@ export class SecurityManager {
|
|||||||
// Start periodic cleanup every 60 seconds
|
// Start periodic cleanup every 60 seconds
|
||||||
this.startPeriodicCleanup();
|
this.startPeriodicCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get connections count by IP
|
* Get connections count by IP (checks normalized variants)
|
||||||
*/
|
*/
|
||||||
public getConnectionCountByIP(ip: string): number {
|
public getConnectionCountByIP(ip: string): number {
|
||||||
return this.connectionsByIP.get(ip)?.size || 0;
|
// Check all normalized variants of the IP
|
||||||
|
const variants = normalizeIP(ip);
|
||||||
|
for (const variant of variants) {
|
||||||
|
const connections = this.connectionsByIP.get(variant);
|
||||||
|
if (connections) {
|
||||||
|
return connections.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check and update connection rate for an IP
|
* Check and update connection rate for an IP
|
||||||
* @returns true if within rate limit, false if exceeding limit
|
* @returns true if within rate limit, false if exceeding limit
|
||||||
@@ -31,43 +40,73 @@ export class SecurityManager {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const minute = 60 * 1000;
|
const minute = 60 * 1000;
|
||||||
|
|
||||||
if (!this.connectionRateByIP.has(ip)) {
|
// Find existing rate tracking (check normalized variants)
|
||||||
this.connectionRateByIP.set(ip, [now]);
|
const variants = normalizeIP(ip);
|
||||||
|
let existingKey: string | null = null;
|
||||||
|
for (const variant of variants) {
|
||||||
|
if (this.connectionRateByIP.has(variant)) {
|
||||||
|
existingKey = variant;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = existingKey || ip;
|
||||||
|
|
||||||
|
if (!this.connectionRateByIP.has(key)) {
|
||||||
|
this.connectionRateByIP.set(key, [now]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get timestamps and filter out entries older than 1 minute
|
// Get timestamps and filter out entries older than 1 minute
|
||||||
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
|
const timestamps = this.connectionRateByIP.get(key)!.filter((time) => now - time < minute);
|
||||||
timestamps.push(now);
|
timestamps.push(now);
|
||||||
this.connectionRateByIP.set(ip, timestamps);
|
this.connectionRateByIP.set(key, timestamps);
|
||||||
|
|
||||||
// Check if rate exceeds limit
|
// Check if rate exceeds limit
|
||||||
return timestamps.length <= this.smartProxy.settings.connectionRateLimitPerMinute!;
|
return timestamps.length <= this.smartProxy.settings.connectionRateLimitPerMinute!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track connection by IP
|
* Track connection by IP
|
||||||
*/
|
*/
|
||||||
public trackConnectionByIP(ip: string, connectionId: string): void {
|
public trackConnectionByIP(ip: string, connectionId: string): void {
|
||||||
if (!this.connectionsByIP.has(ip)) {
|
// Check if any variant already exists
|
||||||
this.connectionsByIP.set(ip, new Set());
|
const variants = normalizeIP(ip);
|
||||||
|
let existingKey: string | null = null;
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
if (this.connectionsByIP.has(variant)) {
|
||||||
|
existingKey = variant;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.connectionsByIP.get(ip)!.add(connectionId);
|
|
||||||
|
const key = existingKey || ip;
|
||||||
|
if (!this.connectionsByIP.has(key)) {
|
||||||
|
this.connectionsByIP.set(key, new Set());
|
||||||
|
}
|
||||||
|
this.connectionsByIP.get(key)!.add(connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove connection tracking for an IP
|
* Remove connection tracking for an IP
|
||||||
*/
|
*/
|
||||||
public removeConnectionByIP(ip: string, connectionId: string): void {
|
public removeConnectionByIP(ip: string, connectionId: string): void {
|
||||||
if (this.connectionsByIP.has(ip)) {
|
// Check all variants to find where the connection is tracked
|
||||||
const connections = this.connectionsByIP.get(ip)!;
|
const variants = normalizeIP(ip);
|
||||||
connections.delete(connectionId);
|
|
||||||
if (connections.size === 0) {
|
for (const variant of variants) {
|
||||||
this.connectionsByIP.delete(ip);
|
if (this.connectionsByIP.has(variant)) {
|
||||||
|
const connections = this.connectionsByIP.get(variant)!;
|
||||||
|
connections.delete(connectionId);
|
||||||
|
if (connections.size === 0) {
|
||||||
|
this.connectionsByIP.delete(variant);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an IP is authorized using security rules
|
* Check if an IP is authorized using security rules
|
||||||
*
|
*
|
||||||
@@ -81,71 +120,7 @@ export class SecurityManager {
|
|||||||
* @returns true if IP is authorized, false if blocked
|
* @returns true if IP is authorized, false if blocked
|
||||||
*/
|
*/
|
||||||
public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean {
|
public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean {
|
||||||
// Skip IP validation if allowedIPs is empty
|
return isIPAuthorized(ip, allowedIPs, blockedIPs);
|
||||||
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First check if IP is blocked - blocked IPs take precedence
|
|
||||||
if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check if IP is allowed
|
|
||||||
return this.isGlobIPMatch(ip, allowedIPs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the IP matches any of the glob patterns from security configuration
|
|
||||||
*
|
|
||||||
* This method checks IP addresses against glob patterns and handles IPv4/IPv6 normalization.
|
|
||||||
* It's used to implement IP filtering based on the route.security configuration.
|
|
||||||
*
|
|
||||||
* @param ip - The IP address to check
|
|
||||||
* @param patterns - Array of glob patterns from security.ipAllowList or ipBlockList
|
|
||||||
* @returns true if IP matches any pattern, false otherwise
|
|
||||||
*/
|
|
||||||
private isGlobIPMatch(ip: string, patterns: string[]): boolean {
|
|
||||||
if (!ip || !patterns || patterns.length === 0) return false;
|
|
||||||
|
|
||||||
// Handle IPv4/IPv6 normalization for proper matching
|
|
||||||
const normalizeIP = (ip: string): string[] => {
|
|
||||||
if (!ip) return [];
|
|
||||||
// Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
|
|
||||||
if (ip.startsWith('::ffff:')) {
|
|
||||||
const ipv4 = ip.slice(7);
|
|
||||||
return [ip, ipv4];
|
|
||||||
}
|
|
||||||
// Handle IPv4 addresses by also checking IPv4-mapped form
|
|
||||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
|
||||||
return [ip, `::ffff:${ip}`];
|
|
||||||
}
|
|
||||||
return [ip];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Normalize the IP being checked
|
|
||||||
const normalizedIPVariants = normalizeIP(ip);
|
|
||||||
if (normalizedIPVariants.length === 0) return false;
|
|
||||||
|
|
||||||
// Expand shorthand patterns and normalize IPs for consistent comparison
|
|
||||||
const expandShorthand = (pattern: string): string => {
|
|
||||||
// Expand shorthand IP patterns like '192.168.*' to '192.168.*.*'
|
|
||||||
if (pattern.includes('*') && !pattern.includes(':')) {
|
|
||||||
const parts = pattern.split('.');
|
|
||||||
while (parts.length < 4) {
|
|
||||||
parts.push('*');
|
|
||||||
}
|
|
||||||
return parts.join('.');
|
|
||||||
}
|
|
||||||
return pattern;
|
|
||||||
};
|
|
||||||
|
|
||||||
const expandedPatterns = patterns.map(expandShorthand).flatMap(normalizeIP);
|
|
||||||
|
|
||||||
// Check for any match between normalized IP variants and patterns
|
|
||||||
return normalizedIPVariants.some((ipVariant) =>
|
|
||||||
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
144
ts/proxies/smart-proxy/utils/route-helpers/api-helpers.ts
Normal file
144
ts/proxies/smart-proxy/utils/route-helpers/api-helpers.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* API Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating API route configurations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||||
|
import { mergeRouteConfigs } from '../route-utils.js';
|
||||||
|
import { createHttpRoute } from './http-helpers.js';
|
||||||
|
import { createHttpsTerminateRoute } from './https-helpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an API route configuration
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param apiPath API base path (e.g., "/api")
|
||||||
|
* @param target Target host and port
|
||||||
|
* @param options Additional route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createApiRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
apiPath: string,
|
||||||
|
target: { host: string | string[]; port: number },
|
||||||
|
options: {
|
||||||
|
useTls?: boolean;
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
addCorsHeaders?: boolean;
|
||||||
|
httpPort?: number | number[];
|
||||||
|
httpsPort?: number | number[];
|
||||||
|
name?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
} = {}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Normalize API path
|
||||||
|
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
|
||||||
|
const pathWithWildcard = normalizedPath.endsWith('/')
|
||||||
|
? `${normalizedPath}*`
|
||||||
|
: `${normalizedPath}/*`;
|
||||||
|
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.useTls
|
||||||
|
? (options.httpsPort || 443)
|
||||||
|
: (options.httpPort || 80),
|
||||||
|
domains,
|
||||||
|
path: pathWithWildcard
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [target]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add TLS configuration if using HTTPS
|
||||||
|
if (options.useTls) {
|
||||||
|
action.tls = {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: options.certificate || 'auto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CORS headers if requested
|
||||||
|
const headers: Record<string, Record<string, string>> = {};
|
||||||
|
if (options.addCorsHeaders) {
|
||||||
|
headers.response = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||||
|
name: options.name || `API Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||||
|
priority: options.priority || 100, // Higher priority for specific path matches
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an API Gateway route pattern
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param apiBasePath Base path for API endpoints (e.g., '/api')
|
||||||
|
* @param target Target host and port
|
||||||
|
* @param options Additional route options
|
||||||
|
* @returns API route configuration
|
||||||
|
*/
|
||||||
|
export function createApiGatewayRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
apiBasePath: string,
|
||||||
|
target: { host: string | string[]; port: number },
|
||||||
|
options: {
|
||||||
|
useTls?: boolean;
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
addCorsHeaders?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
} = {}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Normalize apiBasePath to ensure it starts with / and doesn't end with /
|
||||||
|
const normalizedPath = apiBasePath.startsWith('/')
|
||||||
|
? apiBasePath
|
||||||
|
: `/${apiBasePath}`;
|
||||||
|
|
||||||
|
// Add wildcard to path to match all API endpoints
|
||||||
|
const apiPath = normalizedPath.endsWith('/')
|
||||||
|
? `${normalizedPath}*`
|
||||||
|
: `${normalizedPath}/*`;
|
||||||
|
|
||||||
|
// Create base route
|
||||||
|
const baseRoute = options.useTls
|
||||||
|
? createHttpsTerminateRoute(domains, target, {
|
||||||
|
certificate: options.certificate || 'auto'
|
||||||
|
})
|
||||||
|
: createHttpRoute(domains, target);
|
||||||
|
|
||||||
|
// Add API-specific configurations
|
||||||
|
const apiRoute: Partial<IRouteConfig> = {
|
||||||
|
match: {
|
||||||
|
...baseRoute.match,
|
||||||
|
path: apiPath
|
||||||
|
},
|
||||||
|
name: options.name || `API Gateway: ${apiPath} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`,
|
||||||
|
priority: options.priority || 100 // Higher priority for specific path matching
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add CORS headers if requested
|
||||||
|
if (options.addCorsHeaders) {
|
||||||
|
apiRoute.headers = {
|
||||||
|
response: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeRouteConfigs(baseRoute, apiRoute);
|
||||||
|
}
|
||||||
124
ts/proxies/smart-proxy/utils/route-helpers/dynamic-helpers.ts
Normal file
124
ts/proxies/smart-proxy/utils/route-helpers/dynamic-helpers.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating dynamic routes
|
||||||
|
* with context-based host and port mapping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a helper function that applies a port offset
|
||||||
|
* @param offset The offset to apply to the matched port
|
||||||
|
* @returns A function that adds the offset to the matched port
|
||||||
|
*/
|
||||||
|
export function createPortOffset(offset: number): (context: IRouteContext) => number {
|
||||||
|
return (context: IRouteContext) => context.port + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a port mapping route with context-based port function
|
||||||
|
* @param options Port mapping route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createPortMappingRoute(options: {
|
||||||
|
sourcePortRange: TPortRange;
|
||||||
|
targetHost: string | string[] | ((context: IRouteContext) => string | string[]);
|
||||||
|
portMapper: (context: IRouteContext) => number;
|
||||||
|
name?: string;
|
||||||
|
domains?: string | string[];
|
||||||
|
priority?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}): IRouteConfig {
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.sourcePortRange,
|
||||||
|
domains: options.domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{
|
||||||
|
host: options.targetHost,
|
||||||
|
port: options.portMapper
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: options.name || `Port Mapping Route for ${options.domains || 'all domains'}`,
|
||||||
|
priority: options.priority,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a simple offset port mapping route
|
||||||
|
* @param options Offset port mapping route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createOffsetPortMappingRoute(options: {
|
||||||
|
ports: TPortRange;
|
||||||
|
targetHost: string | string[];
|
||||||
|
offset: number;
|
||||||
|
name?: string;
|
||||||
|
domains?: string | string[];
|
||||||
|
priority?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}): IRouteConfig {
|
||||||
|
return createPortMappingRoute({
|
||||||
|
sourcePortRange: options.ports,
|
||||||
|
targetHost: options.targetHost,
|
||||||
|
portMapper: (context) => context.port + options.offset,
|
||||||
|
name: options.name || `Offset Mapping (${options.offset > 0 ? '+' : ''}${options.offset}) for ${options.domains || 'all domains'}`,
|
||||||
|
domains: options.domains,
|
||||||
|
priority: options.priority,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dynamic route with context-based host and port mapping
|
||||||
|
* @param options Dynamic route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createDynamicRoute(options: {
|
||||||
|
ports: TPortRange;
|
||||||
|
targetHost: (context: IRouteContext) => string | string[];
|
||||||
|
portMapper: (context: IRouteContext) => number;
|
||||||
|
name?: string;
|
||||||
|
domains?: string | string[];
|
||||||
|
path?: string;
|
||||||
|
clientIp?: string[];
|
||||||
|
priority?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}): IRouteConfig {
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.ports,
|
||||||
|
domains: options.domains,
|
||||||
|
path: options.path,
|
||||||
|
clientIp: options.clientIp
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{
|
||||||
|
host: options.targetHost,
|
||||||
|
port: options.portMapper
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: options.name || `Dynamic Route for ${options.domains || 'all domains'}`,
|
||||||
|
priority: options.priority,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
40
ts/proxies/smart-proxy/utils/route-helpers/http-helpers.ts
Normal file
40
ts/proxies/smart-proxy/utils/route-helpers/http-helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* HTTP Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating HTTP route configurations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an HTTP-only route configuration
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param target Target host and port
|
||||||
|
* @param options Additional route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createHttpRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
target: { host: string | string[]; port: number },
|
||||||
|
options: Partial<IRouteConfig> = {}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.match?.ports || 80,
|
||||||
|
domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [target]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: options.name || `HTTP Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
163
ts/proxies/smart-proxy/utils/route-helpers/https-helpers.ts
Normal file
163
ts/proxies/smart-proxy/utils/route-helpers/https-helpers.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* HTTPS Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating HTTPS route configurations
|
||||||
|
* including TLS termination and passthrough routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||||
|
import { SocketHandlers } from './socket-handlers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an HTTPS route with TLS termination
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param target Target host and port
|
||||||
|
* @param options Additional route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createHttpsTerminateRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
target: { host: string | string[]; port: number },
|
||||||
|
options: {
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
httpPort?: number | number[];
|
||||||
|
httpsPort?: number | number[];
|
||||||
|
reencrypt?: boolean;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
} = {}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.httpsPort || 443,
|
||||||
|
domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [target],
|
||||||
|
tls: {
|
||||||
|
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
|
||||||
|
certificate: options.certificate || 'auto'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: options.name || `HTTPS Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an HTTP to HTTPS redirect route
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param httpsPort HTTPS port to redirect to (default: 443)
|
||||||
|
* @param options Additional route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createHttpToHttpsRedirect(
|
||||||
|
domains: string | string[],
|
||||||
|
httpsPort: number = 443,
|
||||||
|
options: Partial<IRouteConfig> = {}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.match?.ports || 80,
|
||||||
|
domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'socket-handler',
|
||||||
|
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an HTTPS passthrough route (SNI-based forwarding without TLS termination)
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param target Target host and port
|
||||||
|
* @param options Additional route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createHttpsPassthroughRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
target: { host: string | string[]; port: number },
|
||||||
|
options: Partial<IRouteConfig> = {}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.match?.ports || 443,
|
||||||
|
domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [target],
|
||||||
|
tls: {
|
||||||
|
mode: 'passthrough'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: options.name || `HTTPS Passthrough for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a complete HTTPS server with HTTP to HTTPS redirects
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param target Target host and port
|
||||||
|
* @param options Additional configuration options
|
||||||
|
* @returns Array of two route configurations (HTTPS and HTTP redirect)
|
||||||
|
*/
|
||||||
|
export function createCompleteHttpsServer(
|
||||||
|
domains: string | string[],
|
||||||
|
target: { host: string | string[]; port: number },
|
||||||
|
options: {
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
httpPort?: number | number[];
|
||||||
|
httpsPort?: number | number[];
|
||||||
|
reencrypt?: boolean;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
} = {}
|
||||||
|
): IRouteConfig[] {
|
||||||
|
// Create the HTTPS route
|
||||||
|
const httpsRoute = createHttpsTerminateRoute(domains, target, options);
|
||||||
|
|
||||||
|
// Create the HTTP redirect route
|
||||||
|
const httpRedirectRoute = createHttpToHttpsRedirect(
|
||||||
|
domains,
|
||||||
|
// Extract the HTTPS port from the HTTPS route - ensure it's a number
|
||||||
|
typeof options.httpsPort === 'number' ? options.httpsPort :
|
||||||
|
Array.isArray(options.httpsPort) ? options.httpsPort[0] : 443,
|
||||||
|
{
|
||||||
|
// Set the HTTP port
|
||||||
|
match: {
|
||||||
|
ports: options.httpPort || 80,
|
||||||
|
domains
|
||||||
|
},
|
||||||
|
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return [httpsRoute, httpRedirectRoute];
|
||||||
|
}
|
||||||
62
ts/proxies/smart-proxy/utils/route-helpers/index.ts
Normal file
62
ts/proxies/smart-proxy/utils/route-helpers/index.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating route configurations for common scenarios.
|
||||||
|
* These functions aim to simplify the creation of route configurations for typical use cases.
|
||||||
|
*
|
||||||
|
* This barrel file re-exports all helper functions for backwards compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// HTTP helpers
|
||||||
|
export { createHttpRoute } from './http-helpers.js';
|
||||||
|
|
||||||
|
// HTTPS helpers
|
||||||
|
export {
|
||||||
|
createHttpsTerminateRoute,
|
||||||
|
createHttpToHttpsRedirect,
|
||||||
|
createHttpsPassthroughRoute,
|
||||||
|
createCompleteHttpsServer
|
||||||
|
} from './https-helpers.js';
|
||||||
|
|
||||||
|
// WebSocket helpers
|
||||||
|
export { createWebSocketRoute } from './websocket-helpers.js';
|
||||||
|
|
||||||
|
// Load balancer helpers
|
||||||
|
export {
|
||||||
|
createLoadBalancerRoute,
|
||||||
|
createSmartLoadBalancer
|
||||||
|
} from './load-balancer-helpers.js';
|
||||||
|
|
||||||
|
// NFTables helpers
|
||||||
|
export {
|
||||||
|
createNfTablesRoute,
|
||||||
|
createNfTablesTerminateRoute,
|
||||||
|
createCompleteNfTablesHttpsServer
|
||||||
|
} from './nftables-helpers.js';
|
||||||
|
|
||||||
|
// Dynamic routing helpers
|
||||||
|
export {
|
||||||
|
createPortOffset,
|
||||||
|
createPortMappingRoute,
|
||||||
|
createOffsetPortMappingRoute,
|
||||||
|
createDynamicRoute
|
||||||
|
} from './dynamic-helpers.js';
|
||||||
|
|
||||||
|
// API helpers
|
||||||
|
export {
|
||||||
|
createApiRoute,
|
||||||
|
createApiGatewayRoute
|
||||||
|
} from './api-helpers.js';
|
||||||
|
|
||||||
|
// Security helpers
|
||||||
|
export {
|
||||||
|
addRateLimiting,
|
||||||
|
addBasicAuth,
|
||||||
|
addJwtAuth
|
||||||
|
} from './security-helpers.js';
|
||||||
|
|
||||||
|
// Socket handlers
|
||||||
|
export {
|
||||||
|
SocketHandlers,
|
||||||
|
createSocketHandlerRoute
|
||||||
|
} from './socket-handlers.js';
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Load Balancer Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating load balancer route configurations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a load balancer route (round-robin between multiple backend hosts)
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param backendsOrHosts Array of backend servers OR array of host strings (legacy)
|
||||||
|
* @param portOrOptions Port number (legacy) OR options object
|
||||||
|
* @param options Additional route options (legacy)
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createLoadBalancerRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
backendsOrHosts: Array<{ host: string; port: number }> | string[],
|
||||||
|
portOrOptions?: number | {
|
||||||
|
tls?: {
|
||||||
|
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
};
|
||||||
|
useTls?: boolean;
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
algorithm?: 'round-robin' | 'least-connections' | 'ip-hash';
|
||||||
|
healthCheck?: {
|
||||||
|
path: string;
|
||||||
|
interval: number;
|
||||||
|
timeout: number;
|
||||||
|
unhealthyThreshold: number;
|
||||||
|
healthyThreshold: number;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
},
|
||||||
|
options?: {
|
||||||
|
tls?: {
|
||||||
|
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Handle legacy signature: (domains, hosts[], port, options)
|
||||||
|
let backends: Array<{ host: string; port: number }>;
|
||||||
|
let finalOptions: any;
|
||||||
|
|
||||||
|
if (Array.isArray(backendsOrHosts) && backendsOrHosts.length > 0 && typeof backendsOrHosts[0] === 'string') {
|
||||||
|
// Legacy signature
|
||||||
|
const hosts = backendsOrHosts as string[];
|
||||||
|
const port = portOrOptions as number;
|
||||||
|
backends = hosts.map(host => ({ host, port }));
|
||||||
|
finalOptions = options || {};
|
||||||
|
} else {
|
||||||
|
// New signature
|
||||||
|
backends = backendsOrHosts as Array<{ host: string; port: number }>;
|
||||||
|
finalOptions = (portOrOptions as any) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract hosts and ensure all backends use the same port
|
||||||
|
const port = backends[0].port;
|
||||||
|
const hosts = backends.map(backend => backend.host);
|
||||||
|
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: finalOptions.match?.ports || (finalOptions.tls || finalOptions.useTls ? 443 : 80),
|
||||||
|
domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route target
|
||||||
|
const target: IRouteTarget = {
|
||||||
|
host: hosts,
|
||||||
|
port
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [target]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add TLS configuration if provided
|
||||||
|
if (finalOptions.tls || finalOptions.useTls) {
|
||||||
|
action.tls = {
|
||||||
|
mode: finalOptions.tls?.mode || 'terminate',
|
||||||
|
certificate: finalOptions.tls?.certificate || finalOptions.certificate || 'auto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add load balancing options
|
||||||
|
if (finalOptions.algorithm || finalOptions.healthCheck) {
|
||||||
|
action.loadBalancing = {
|
||||||
|
algorithm: finalOptions.algorithm || 'round-robin',
|
||||||
|
healthCheck: finalOptions.healthCheck
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: finalOptions.name || `Load Balancer for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||||
|
...finalOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a smart load balancer with dynamic domain-based backend selection
|
||||||
|
* @param options Smart load balancer options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createSmartLoadBalancer(options: {
|
||||||
|
ports: TPortRange;
|
||||||
|
domainTargets: Record<string, string | string[]>;
|
||||||
|
portMapper: (context: IRouteContext) => number;
|
||||||
|
name?: string;
|
||||||
|
defaultTarget?: string | string[];
|
||||||
|
priority?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}): IRouteConfig {
|
||||||
|
// Extract all domain keys to create the match criteria
|
||||||
|
const domains = Object.keys(options.domainTargets);
|
||||||
|
|
||||||
|
// Create the smart host selector function
|
||||||
|
const hostSelector = (context: IRouteContext) => {
|
||||||
|
const domain = context.domain || '';
|
||||||
|
return options.domainTargets[domain] || options.defaultTarget || 'localhost';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: options.ports,
|
||||||
|
domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{
|
||||||
|
host: hostSelector,
|
||||||
|
port: options.portMapper
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: options.name || `Smart Load Balancer for ${domains.join(', ')}`,
|
||||||
|
priority: options.priority,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
202
ts/proxies/smart-proxy/utils/route-helpers/nftables-helpers.ts
Normal file
202
ts/proxies/smart-proxy/utils/route-helpers/nftables-helpers.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* NFTables Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating NFTables-based route configurations
|
||||||
|
* for high-performance packet forwarding at the kernel level.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../../models/route-types.js';
|
||||||
|
import { createHttpToHttpsRedirect } from './https-helpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
targets: [{
|
||||||
|
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 TLS options if needed
|
||||||
|
if (options.useTls) {
|
||||||
|
action.tls = {
|
||||||
|
mode: 'passthrough'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
const routeConfig: IRouteConfig = {
|
||||||
|
name,
|
||||||
|
match,
|
||||||
|
action
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add security if allowed or blocked IPs are specified
|
||||||
|
if (options.ipAllowList?.length || options.ipBlockList?.length) {
|
||||||
|
routeConfig.security = {
|
||||||
|
ipAllowList: options.ipAllowList,
|
||||||
|
ipBlockList: options.ipBlockList
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return routeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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];
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Security Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for adding security features to routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig } from '../../models/route-types.js';
|
||||||
|
import { mergeRouteConfigs } from '../route-utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a rate limiting route pattern
|
||||||
|
* @param baseRoute Base route to add rate limiting to
|
||||||
|
* @param rateLimit Rate limiting configuration
|
||||||
|
* @returns Route with rate limiting
|
||||||
|
*/
|
||||||
|
export function addRateLimiting(
|
||||||
|
baseRoute: IRouteConfig,
|
||||||
|
rateLimit: {
|
||||||
|
maxRequests: number;
|
||||||
|
window: number; // Time window in seconds
|
||||||
|
keyBy?: 'ip' | 'path' | 'header';
|
||||||
|
headerName?: string; // Required if keyBy is 'header'
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
): IRouteConfig {
|
||||||
|
return mergeRouteConfigs(baseRoute, {
|
||||||
|
security: {
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: rateLimit.maxRequests,
|
||||||
|
window: rateLimit.window,
|
||||||
|
keyBy: rateLimit.keyBy || 'ip',
|
||||||
|
headerName: rateLimit.headerName,
|
||||||
|
errorMessage: rateLimit.errorMessage || 'Rate limit exceeded. Please try again later.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a basic authentication route pattern
|
||||||
|
* @param baseRoute Base route to add authentication to
|
||||||
|
* @param auth Authentication configuration
|
||||||
|
* @returns Route with basic authentication
|
||||||
|
*/
|
||||||
|
export function addBasicAuth(
|
||||||
|
baseRoute: IRouteConfig,
|
||||||
|
auth: {
|
||||||
|
users: Array<{ username: string; password: string }>;
|
||||||
|
realm?: string;
|
||||||
|
excludePaths?: string[];
|
||||||
|
}
|
||||||
|
): IRouteConfig {
|
||||||
|
return mergeRouteConfigs(baseRoute, {
|
||||||
|
security: {
|
||||||
|
basicAuth: {
|
||||||
|
enabled: true,
|
||||||
|
users: auth.users,
|
||||||
|
realm: auth.realm || 'Restricted Area',
|
||||||
|
excludePaths: auth.excludePaths || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a JWT authentication route pattern
|
||||||
|
* @param baseRoute Base route to add JWT authentication to
|
||||||
|
* @param jwt JWT authentication configuration
|
||||||
|
* @returns Route with JWT authentication
|
||||||
|
*/
|
||||||
|
export function addJwtAuth(
|
||||||
|
baseRoute: IRouteConfig,
|
||||||
|
jwt: {
|
||||||
|
secret: string;
|
||||||
|
algorithm?: string;
|
||||||
|
issuer?: string;
|
||||||
|
audience?: string;
|
||||||
|
expiresIn?: number; // Time in seconds
|
||||||
|
excludePaths?: string[];
|
||||||
|
}
|
||||||
|
): IRouteConfig {
|
||||||
|
return mergeRouteConfigs(baseRoute, {
|
||||||
|
security: {
|
||||||
|
jwtAuth: {
|
||||||
|
enabled: true,
|
||||||
|
secret: jwt.secret,
|
||||||
|
algorithm: jwt.algorithm || 'HS256',
|
||||||
|
issuer: jwt.issuer,
|
||||||
|
audience: jwt.audience,
|
||||||
|
expiresIn: jwt.expiresIn,
|
||||||
|
excludePaths: jwt.excludePaths || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
337
ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts
Normal file
337
ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Socket Handler Functions
|
||||||
|
*
|
||||||
|
* This module provides pre-built socket handlers for common use cases
|
||||||
|
* like echoing, proxying, HTTP responses, and redirects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../../../../plugins.js';
|
||||||
|
import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||||
|
import { ProtocolDetector } from '../../../../detection/index.js';
|
||||||
|
import { createSocketTracker } from '../../../../core/utils/socket-tracker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-built socket handlers for common use cases
|
||||||
|
*/
|
||||||
|
export const SocketHandlers = {
|
||||||
|
/**
|
||||||
|
* Simple echo server handler
|
||||||
|
*/
|
||||||
|
echo: (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
socket.write('ECHO SERVER READY\n');
|
||||||
|
socket.on('data', data => socket.write(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TCP proxy handler
|
||||||
|
*/
|
||||||
|
proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
const target = plugins.net.connect(targetPort, targetHost);
|
||||||
|
socket.pipe(target);
|
||||||
|
target.pipe(socket);
|
||||||
|
socket.on('close', () => target.destroy());
|
||||||
|
target.on('close', () => socket.destroy());
|
||||||
|
target.on('error', (err) => {
|
||||||
|
console.error('Proxy target error:', err);
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line-based protocol handler
|
||||||
|
*/
|
||||||
|
lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
let buffer = '';
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (line.trim()) {
|
||||||
|
handler(line.trim(), socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple HTTP response handler (for testing)
|
||||||
|
*/
|
||||||
|
httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
const response = [
|
||||||
|
`HTTP/1.1 ${statusCode} ${statusCode === 200 ? 'OK' : 'Error'}`,
|
||||||
|
'Content-Type: text/plain',
|
||||||
|
`Content-Length: ${body.length}`,
|
||||||
|
'Connection: close',
|
||||||
|
'',
|
||||||
|
body
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
socket.write(response);
|
||||||
|
socket.end();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block connection immediately
|
||||||
|
*/
|
||||||
|
block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
const finalMessage = message || `Connection blocked from ${context.clientIp}`;
|
||||||
|
if (finalMessage) {
|
||||||
|
socket.write(finalMessage);
|
||||||
|
}
|
||||||
|
socket.end();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP block response
|
||||||
|
*/
|
||||||
|
httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`;
|
||||||
|
const finalMessage = message || defaultMessage;
|
||||||
|
|
||||||
|
const response = [
|
||||||
|
`HTTP/1.1 ${statusCode} ${finalMessage}`,
|
||||||
|
'Content-Type: text/plain',
|
||||||
|
`Content-Length: ${finalMessage.length}`,
|
||||||
|
'Connection: close',
|
||||||
|
'',
|
||||||
|
finalMessage
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
socket.write(response);
|
||||||
|
socket.end();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP redirect handler
|
||||||
|
* Uses the centralized detection module for HTTP parsing
|
||||||
|
*/
|
||||||
|
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
const tracker = createSocketTracker(socket);
|
||||||
|
const connectionId = ProtocolDetector.createConnectionId({
|
||||||
|
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleData = async (data: Buffer) => {
|
||||||
|
// Use detection module for parsing
|
||||||
|
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
||||||
|
data,
|
||||||
|
connectionId,
|
||||||
|
{ extractFullHeaders: false } // We only need method and path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detectionResult.protocol === 'http' && detectionResult.connectionInfo.path) {
|
||||||
|
const method = detectionResult.connectionInfo.method || 'GET';
|
||||||
|
const path = detectionResult.connectionInfo.path || '/';
|
||||||
|
|
||||||
|
const domain = context.domain || 'localhost';
|
||||||
|
const port = context.port;
|
||||||
|
|
||||||
|
let finalLocation = locationTemplate
|
||||||
|
.replace('{domain}', domain)
|
||||||
|
.replace('{port}', String(port))
|
||||||
|
.replace('{path}', path)
|
||||||
|
.replace('{clientIp}', context.clientIp);
|
||||||
|
|
||||||
|
const message = `Redirecting to ${finalLocation}`;
|
||||||
|
const response = [
|
||||||
|
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
||||||
|
`Location: ${finalLocation}`,
|
||||||
|
'Content-Type: text/plain',
|
||||||
|
`Content-Length: ${message.length}`,
|
||||||
|
'Connection: close',
|
||||||
|
'',
|
||||||
|
message
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
socket.write(response);
|
||||||
|
} else {
|
||||||
|
// Not a valid HTTP request, close connection
|
||||||
|
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.end();
|
||||||
|
// Clean up detection state
|
||||||
|
ProtocolDetector.cleanupConnections();
|
||||||
|
// Clean up all tracked resources
|
||||||
|
tracker.cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use tracker to manage the listener
|
||||||
|
socket.once('data', handleData);
|
||||||
|
|
||||||
|
tracker.addListener('error', (err) => {
|
||||||
|
tracker.safeDestroy(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.addListener('close', () => {
|
||||||
|
tracker.cleanup();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP server handler for ACME challenges and other HTTP needs
|
||||||
|
* Uses the centralized detection module for HTTP parsing
|
||||||
|
*/
|
||||||
|
httpServer: (handler: (req: { method: string; url: string; headers: Record<string, string>; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||||
|
const tracker = createSocketTracker(socket);
|
||||||
|
let requestParsed = false;
|
||||||
|
let responseTimer: NodeJS.Timeout | null = null;
|
||||||
|
const connectionId = ProtocolDetector.createConnectionId({
|
||||||
|
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const processData = async (data: Buffer) => {
|
||||||
|
if (requestParsed) return; // Only handle the first request
|
||||||
|
|
||||||
|
// Use HttpDetector for parsing
|
||||||
|
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
||||||
|
data,
|
||||||
|
connectionId,
|
||||||
|
{ extractFullHeaders: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detectionResult.protocol !== 'http' || !detectionResult.isComplete) {
|
||||||
|
// Not a complete HTTP request yet
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestParsed = true;
|
||||||
|
// Remove data listener after parsing request
|
||||||
|
socket.removeListener('data', processData);
|
||||||
|
const connInfo = detectionResult.connectionInfo;
|
||||||
|
|
||||||
|
// Create request object from detection result
|
||||||
|
const req = {
|
||||||
|
method: connInfo.method || 'GET',
|
||||||
|
url: connInfo.path || '/',
|
||||||
|
headers: connInfo.headers || {},
|
||||||
|
body: detectionResult.remainingBuffer?.toString() || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create response object
|
||||||
|
let statusCode = 200;
|
||||||
|
const responseHeaders: Record<string, string> = {};
|
||||||
|
let ended = false;
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
status: (code: number) => {
|
||||||
|
statusCode = code;
|
||||||
|
},
|
||||||
|
header: (name: string, value: string) => {
|
||||||
|
responseHeaders[name] = value;
|
||||||
|
},
|
||||||
|
send: (data: string) => {
|
||||||
|
if (ended) return;
|
||||||
|
ended = true;
|
||||||
|
|
||||||
|
// Clear response timer since we're sending now
|
||||||
|
if (responseTimer) {
|
||||||
|
clearTimeout(responseTimer);
|
||||||
|
responseTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseHeaders['content-type']) {
|
||||||
|
responseHeaders['content-type'] = 'text/plain';
|
||||||
|
}
|
||||||
|
responseHeaders['content-length'] = String(data.length);
|
||||||
|
responseHeaders['connection'] = 'close';
|
||||||
|
|
||||||
|
const statusText = statusCode === 200 ? 'OK' :
|
||||||
|
statusCode === 404 ? 'Not Found' :
|
||||||
|
statusCode === 500 ? 'Internal Server Error' : 'Response';
|
||||||
|
|
||||||
|
let response = `HTTP/1.1 ${statusCode} ${statusText}\r\n`;
|
||||||
|
for (const [name, value] of Object.entries(responseHeaders)) {
|
||||||
|
response += `${name}: ${value}\r\n`;
|
||||||
|
}
|
||||||
|
response += '\r\n';
|
||||||
|
response += data;
|
||||||
|
|
||||||
|
socket.write(response);
|
||||||
|
socket.end();
|
||||||
|
},
|
||||||
|
end: () => {
|
||||||
|
if (ended) return;
|
||||||
|
ended = true;
|
||||||
|
socket.write('HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
handler(req, res);
|
||||||
|
// Ensure response is sent even if handler doesn't call send()
|
||||||
|
responseTimer = setTimeout(() => {
|
||||||
|
if (!ended) {
|
||||||
|
res.send('');
|
||||||
|
}
|
||||||
|
responseTimer = null;
|
||||||
|
}, 1000);
|
||||||
|
// Track and unref the timer
|
||||||
|
tracker.addTimer(responseTimer);
|
||||||
|
} catch (error) {
|
||||||
|
if (!ended) {
|
||||||
|
res.status(500);
|
||||||
|
res.send('Internal Server Error');
|
||||||
|
}
|
||||||
|
// Use safeDestroy for error cases
|
||||||
|
tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use tracker to manage listeners
|
||||||
|
tracker.addListener('data', processData);
|
||||||
|
|
||||||
|
tracker.addListener('error', (err) => {
|
||||||
|
if (!requestParsed) {
|
||||||
|
tracker.safeDestroy(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.addListener('close', () => {
|
||||||
|
// Clear any pending response timer
|
||||||
|
if (responseTimer) {
|
||||||
|
clearTimeout(responseTimer);
|
||||||
|
responseTimer = null;
|
||||||
|
}
|
||||||
|
// Clean up detection state
|
||||||
|
ProtocolDetector.cleanupConnections();
|
||||||
|
// Clean up all tracked resources
|
||||||
|
tracker.cleanup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a socket handler route configuration
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param ports Port(s) to listen on
|
||||||
|
* @param handler Socket handler function
|
||||||
|
* @param options Additional route options
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createSocketHandlerRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
ports: TPortRange,
|
||||||
|
handler: (socket: plugins.net.Socket) => void | Promise<void>,
|
||||||
|
options: {
|
||||||
|
name?: string;
|
||||||
|
priority?: number;
|
||||||
|
path?: string;
|
||||||
|
} = {}
|
||||||
|
): IRouteConfig {
|
||||||
|
return {
|
||||||
|
name: options.name || 'socket-handler-route',
|
||||||
|
priority: options.priority !== undefined ? options.priority : 50,
|
||||||
|
match: {
|
||||||
|
domains,
|
||||||
|
ports,
|
||||||
|
...(options.path && { path: options.path })
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'socket-handler',
|
||||||
|
socketHandler: handler
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket Route Helper Functions
|
||||||
|
*
|
||||||
|
* This module provides utility functions for creating WebSocket route configurations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a WebSocket route configuration
|
||||||
|
* @param domains Domain(s) to match
|
||||||
|
* @param targetOrPath Target server OR WebSocket path (legacy)
|
||||||
|
* @param targetOrOptions Target server (legacy) OR options
|
||||||
|
* @param options Additional route options (legacy)
|
||||||
|
* @returns Route configuration object
|
||||||
|
*/
|
||||||
|
export function createWebSocketRoute(
|
||||||
|
domains: string | string[],
|
||||||
|
targetOrPath: { host: string | string[]; port: number } | string,
|
||||||
|
targetOrOptions?: { host: string | string[]; port: number } | {
|
||||||
|
useTls?: boolean;
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
path?: string;
|
||||||
|
httpPort?: number | number[];
|
||||||
|
httpsPort?: number | number[];
|
||||||
|
pingInterval?: number;
|
||||||
|
pingTimeout?: number;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
},
|
||||||
|
options?: {
|
||||||
|
useTls?: boolean;
|
||||||
|
certificate?: 'auto' | { key: string; cert: string };
|
||||||
|
httpPort?: number | number[];
|
||||||
|
httpsPort?: number | number[];
|
||||||
|
pingInterval?: number;
|
||||||
|
pingTimeout?: number;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
): IRouteConfig {
|
||||||
|
// Handle different signatures
|
||||||
|
let target: { host: string | string[]; port: number };
|
||||||
|
let wsPath: string;
|
||||||
|
let finalOptions: any;
|
||||||
|
|
||||||
|
if (typeof targetOrPath === 'string') {
|
||||||
|
// Legacy signature: (domains, path, target, options)
|
||||||
|
wsPath = targetOrPath;
|
||||||
|
target = targetOrOptions as { host: string | string[]; port: number };
|
||||||
|
finalOptions = options || {};
|
||||||
|
} else {
|
||||||
|
// New signature: (domains, target, options)
|
||||||
|
target = targetOrPath;
|
||||||
|
finalOptions = (targetOrOptions as any) || {};
|
||||||
|
wsPath = finalOptions.path || '/ws';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize WebSocket path
|
||||||
|
const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`;
|
||||||
|
|
||||||
|
// Create route match
|
||||||
|
const match: IRouteMatch = {
|
||||||
|
ports: finalOptions.useTls
|
||||||
|
? (finalOptions.httpsPort || 443)
|
||||||
|
: (finalOptions.httpPort || 80),
|
||||||
|
domains,
|
||||||
|
path: normalizedPath
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create route action
|
||||||
|
const action: IRouteAction = {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [target],
|
||||||
|
websocket: {
|
||||||
|
enabled: true,
|
||||||
|
pingInterval: finalOptions.pingInterval || 30000, // 30 seconds
|
||||||
|
pingTimeout: finalOptions.pingTimeout || 5000 // 5 seconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add TLS configuration if using HTTPS
|
||||||
|
if (finalOptions.useTls) {
|
||||||
|
action.tls = {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: finalOptions.certificate || 'auto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the route config
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
action,
|
||||||
|
name: finalOptions.name || `WebSocket Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||||
|
priority: finalOptions.priority || 100, // Higher priority for WebSocket routes
|
||||||
|
...finalOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user