feat(proxies): introduce nftables command executor and utilities, default certificate provider, expanded route/socket helper modules, and security improvements
This commit is contained in:
@@ -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 { IRouteContext, IHttpRouteContext } 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 { RequestHandler, type IMetricsTracker } from './request-handler.js';
|
||||
import { WebSocketHandler } from './websocket-handler.js';
|
||||
@@ -38,7 +38,7 @@ export class HttpProxy implements IMetricsTracker {
|
||||
public httpsServer: plugins.http2.Http2SecureServer;
|
||||
|
||||
// Core components
|
||||
private certificateManager: CertificateManager;
|
||||
private defaultCertProvider: DefaultCertificateProvider;
|
||||
private connectionPool: ConnectionPool;
|
||||
private requestHandler: RequestHandler;
|
||||
private webSocketHandler: WebSocketHandler;
|
||||
@@ -126,7 +126,7 @@ export class HttpProxy implements IMetricsTracker {
|
||||
);
|
||||
|
||||
// Initialize other components
|
||||
this.certificateManager = new CertificateManager(this.options);
|
||||
this.defaultCertProvider = new DefaultCertificateProvider(this.logger);
|
||||
this.connectionPool = new ConnectionPool(this.options);
|
||||
this.requestHandler = new RequestHandler(
|
||||
this.options,
|
||||
@@ -237,10 +237,11 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Create HTTP/2 server with HTTP/1 fallback
|
||||
const defaultCerts = this.defaultCertProvider.getDefaultCertificates();
|
||||
this.httpsServer = plugins.http2.createSecureServer(
|
||||
{
|
||||
key: this.certificateManager.getDefaultCertificates().key,
|
||||
cert: this.certificateManager.getDefaultCertificates().cert,
|
||||
key: defaultCerts.key,
|
||||
cert: defaultCerts.cert,
|
||||
allowHTTP1: true,
|
||||
ALPNProtocols: ['h2', 'http/1.1']
|
||||
}
|
||||
@@ -258,9 +259,6 @@ export class HttpProxy implements IMetricsTracker {
|
||||
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
|
||||
this.webSocketHandler.initialize(this.httpsServer as any);
|
||||
// Start metrics logging
|
||||
@@ -506,10 +504,6 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.requestHandler.securityManager.setRoutes(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
|
||||
const currentHostnames = new Set<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
|
||||
for (const [domain, certData] of certificateUpdates.entries()) {
|
||||
try {
|
||||
this.certificateManager.updateCertificate(
|
||||
this.defaultCertProvider.updateCertificate(
|
||||
domain,
|
||||
certData.cert,
|
||||
certData.key
|
||||
@@ -663,7 +657,7 @@ export class HttpProxy implements IMetricsTracker {
|
||||
expiryDate?: Date
|
||||
): void {
|
||||
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 } from './http-proxy.js';
|
||||
export { CertificateManager } from './certificate-manager.js';
|
||||
export { DefaultCertificateProvider } from './default-certificates.js';
|
||||
export { ConnectionPool } from './connection-pool.js';
|
||||
export { RequestHandler } from './request-handler.js';
|
||||
export type { IMetricsTracker, MetricsTracker } from './request-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 { IRouteConfig } from '../smart-proxy/models/route-types.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
|
||||
* Implements Phase 5.4: Security features like IP filtering and rate limiting
|
||||
* Manages security features for the HttpProxy
|
||||
* Implements IP filtering, rate limiting, and authentication.
|
||||
* Uses shared utilities from security-utils.ts.
|
||||
*/
|
||||
export class SecurityManager {
|
||||
// Cache IP filtering results to avoid constant regex matching
|
||||
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
|
||||
|
||||
|
||||
// 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
|
||||
private connectionsByIP: Map<string, Set<string>> = 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
|
||||
this.startPeriodicIpCleanup();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the routes configuration
|
||||
*/
|
||||
@@ -31,10 +43,10 @@ export class SecurityManager {
|
||||
// Reset caches when routes change
|
||||
this.ipFilterCache.clear();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a client is allowed to access a specific route
|
||||
*
|
||||
*
|
||||
* @param route The route to check access for
|
||||
* @param context The route context with client information
|
||||
* @returns True if access is allowed, false otherwise
|
||||
@@ -43,26 +55,26 @@ export class SecurityManager {
|
||||
if (!route.security) {
|
||||
return true; // No security restrictions
|
||||
}
|
||||
|
||||
|
||||
// --- IP filtering ---
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// --- Rate limiting ---
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// --- Basic Auth (handled at HTTP level) ---
|
||||
// Basic auth is not checked here as it requires HTTP headers
|
||||
// and is handled in the RequestHandler
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if an IP is allowed based on route security settings
|
||||
*/
|
||||
@@ -70,94 +82,32 @@ export class SecurityManager {
|
||||
if (!route.security) {
|
||||
return true; // No security restrictions
|
||||
}
|
||||
|
||||
const routeId = route.id || route.name || 'unnamed';
|
||||
|
||||
|
||||
const routeId = route.name || 'unnamed';
|
||||
|
||||
// Check cache first
|
||||
if (!this.ipFilterCache.has(routeId)) {
|
||||
this.ipFilterCache.set(routeId, new Map());
|
||||
}
|
||||
|
||||
|
||||
const routeCache = this.ipFilterCache.get(routeId)!;
|
||||
if (routeCache.has(clientIp)) {
|
||||
return routeCache.get(clientIp)!;
|
||||
}
|
||||
|
||||
let allowed = true;
|
||||
|
||||
// Check block list first (deny has priority over allow)
|
||||
if (route.security.ipBlockList && route.security.ipBlockList.length > 0) {
|
||||
if (this.ipMatchesPattern(clientIp, 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);
|
||||
}
|
||||
|
||||
|
||||
// Use shared utility for IP authorization
|
||||
const allowed = isIPAuthorized(
|
||||
clientIp,
|
||||
route.security.ipAllowList,
|
||||
route.security.ipBlockList
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
routeCache.set(clientIp, 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
|
||||
*/
|
||||
@@ -165,13 +115,13 @@ export class SecurityManager {
|
||||
if (!route.security?.rateLimit?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
let key = context.clientIp; // Default to IP
|
||||
|
||||
|
||||
if (rateLimit.keyBy === 'path' && context.path) {
|
||||
key = `${context.clientIp}:${context.path}`;
|
||||
} else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) {
|
||||
@@ -180,15 +130,15 @@ export class SecurityManager {
|
||||
key = `${context.clientIp}:${headerValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get or create rate limit tracking for this route
|
||||
if (!this.rateLimits.has(routeId)) {
|
||||
this.rateLimits.set(routeId, new Map());
|
||||
}
|
||||
|
||||
|
||||
const routeLimits = this.rateLimits.get(routeId)!;
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Get or create rate limit tracking for this key
|
||||
let limit = routeLimits.get(key);
|
||||
if (!limit || limit.expiry < now) {
|
||||
@@ -200,37 +150,30 @@ export class SecurityManager {
|
||||
routeLimits.set(key, limit);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Increment the counter
|
||||
limit.count++;
|
||||
|
||||
|
||||
// Check if rate limit is exceeded
|
||||
return limit.count <= rateLimit.maxRequests;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clean up expired rate limits
|
||||
* Should be called periodically to prevent memory leaks
|
||||
*/
|
||||
public cleanupExpiredRateLimits(): void {
|
||||
const now = Date.now();
|
||||
for (const [routeId, routeLimits] of this.rateLimits.entries()) {
|
||||
let removed = 0;
|
||||
for (const [key, limit] of routeLimits.entries()) {
|
||||
if (limit.expiry < now) {
|
||||
routeLimits.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`);
|
||||
}
|
||||
}
|
||||
cleanupExpiredRateLimits(this.rateLimits, {
|
||||
info: this.logger.info.bind(this.logger),
|
||||
warn: this.logger.warn.bind(this.logger),
|
||||
error: this.logger.error.bind(this.logger),
|
||||
debug: this.logger.debug?.bind(this.logger)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check basic auth credentials
|
||||
*
|
||||
*
|
||||
* @param route The route to check auth for
|
||||
* @param username The provided username
|
||||
* @param password The provided password
|
||||
@@ -240,22 +183,22 @@ export class SecurityManager {
|
||||
if (!route.security?.basicAuth?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
const basicAuth = route.security.basicAuth;
|
||||
|
||||
|
||||
// Check credentials against configured users
|
||||
for (const user of basicAuth.users) {
|
||||
if (user.username === username && user.password === password) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*
|
||||
*
|
||||
* @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
|
||||
@@ -264,38 +207,37 @@ export class SecurityManager {
|
||||
if (!route.security?.jwtAuth?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// This is a simplified version - in production you'd use a proper JWT library
|
||||
const jwtAuth = route.security.jwtAuth;
|
||||
|
||||
|
||||
// Verify structure
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
return true;
|
||||
} catch (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 {
|
||||
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
|
||||
* @returns true if within rate limit, false if exceeding limit
|
||||
@@ -318,43 +268,73 @@ export class SecurityManager {
|
||||
const now = Date.now();
|
||||
const minute = 60 * 1000;
|
||||
|
||||
if (!this.connectionRateByIP.has(ip)) {
|
||||
this.connectionRateByIP.set(ip, [now]);
|
||||
// Find existing rate tracking (check normalized variants)
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
this.connectionRateByIP.set(ip, timestamps);
|
||||
this.connectionRateByIP.set(key, timestamps);
|
||||
|
||||
// Check if rate exceeds limit
|
||||
return timestamps.length <= this.connectionRateLimitPerMinute;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Track connection by IP
|
||||
*/
|
||||
public trackConnectionByIP(ip: string, connectionId: string): void {
|
||||
if (!this.connectionsByIP.has(ip)) {
|
||||
this.connectionsByIP.set(ip, new Set());
|
||||
// Check if any variant already exists
|
||||
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
|
||||
*/
|
||||
public removeConnectionByIP(ip: string, connectionId: string): void {
|
||||
if (this.connectionsByIP.has(ip)) {
|
||||
const connections = this.connectionsByIP.get(ip)!;
|
||||
connections.delete(connectionId);
|
||||
if (connections.size === 0) {
|
||||
this.connectionsByIP.delete(ip);
|
||||
// Check all variants to find where the connection is tracked
|
||||
const variants = normalizeIP(ip);
|
||||
|
||||
for (const variant of variants) {
|
||||
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
|
||||
* @returns Object with result and reason
|
||||
@@ -375,10 +355,10 @@ export class SecurityManager {
|
||||
reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clears all IP tracking data (for shutdown)
|
||||
*/
|
||||
@@ -386,7 +366,7 @@ export class SecurityManager {
|
||||
this.connectionsByIP.clear();
|
||||
this.connectionRateByIP.clear();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of IP tracking data
|
||||
*/
|
||||
@@ -396,7 +376,7 @@ export class SecurityManager {
|
||||
this.performIpCleanup();
|
||||
}, 60000).unref();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Perform cleanup of expired IP data
|
||||
*/
|
||||
@@ -405,11 +385,11 @@ export class SecurityManager {
|
||||
const minute = 60 * 1000;
|
||||
let cleanedRateLimits = 0;
|
||||
let cleanedIPs = 0;
|
||||
|
||||
|
||||
// Clean up expired rate limit timestamps
|
||||
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) {
|
||||
this.connectionRateByIP.delete(ip);
|
||||
cleanedRateLimits++;
|
||||
@@ -417,7 +397,7 @@ export class SecurityManager {
|
||||
this.connectionRateByIP.set(ip, validTimestamps);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clean up IPs with no active connections
|
||||
for (const [ip, connections] of this.connectionsByIP.entries()) {
|
||||
if (connections.size === 0) {
|
||||
@@ -425,7 +405,7 @@ export class SecurityManager {
|
||||
cleanedIPs++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
|
||||
this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user