245 lines
7.8 KiB
TypeScript
245 lines
7.8 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import type { SmartProxy } from './smart-proxy.js';
|
|
import { logger } from '../../core/utils/logger.js';
|
|
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
|
|
|
/**
|
|
* Handles security aspects like IP tracking, rate limiting, and authorization
|
|
*/
|
|
export class SecurityManager {
|
|
private connectionsByIP: Map<string, Set<string>> = new Map();
|
|
private connectionRateByIP: Map<string, number[]> = new Map();
|
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
|
|
constructor(private smartProxy: SmartProxy) {
|
|
// Start periodic cleanup every 60 seconds
|
|
this.startPeriodicCleanup();
|
|
}
|
|
|
|
/**
|
|
* Get connections count by IP
|
|
*/
|
|
public getConnectionCountByIP(ip: string): number {
|
|
return this.connectionsByIP.get(ip)?.size || 0;
|
|
}
|
|
|
|
/**
|
|
* Check and update connection rate for an IP
|
|
* @returns true if within rate limit, false if exceeding limit
|
|
*/
|
|
public checkConnectionRate(ip: string): boolean {
|
|
const now = Date.now();
|
|
const minute = 60 * 1000;
|
|
|
|
if (!this.connectionRateByIP.has(ip)) {
|
|
this.connectionRateByIP.set(ip, [now]);
|
|
return true;
|
|
}
|
|
|
|
// Get timestamps and filter out entries older than 1 minute
|
|
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
|
|
timestamps.push(now);
|
|
this.connectionRateByIP.set(ip, timestamps);
|
|
|
|
// Check if rate exceeds limit
|
|
return timestamps.length <= this.smartProxy.settings.connectionRateLimitPerMinute!;
|
|
}
|
|
|
|
/**
|
|
* Track connection by IP
|
|
*/
|
|
public trackConnectionByIP(ip: string, connectionId: string): void {
|
|
if (!this.connectionsByIP.has(ip)) {
|
|
this.connectionsByIP.set(ip, new Set());
|
|
}
|
|
this.connectionsByIP.get(ip)!.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 if an IP is authorized using security rules
|
|
*
|
|
* This method is used to determine if an IP is allowed to connect, based on security
|
|
* rules configured in the route configuration. The allowed and blocked IPs are
|
|
* typically derived from route.security.ipAllowList and ipBlockList.
|
|
*
|
|
* @param ip - The IP address to check
|
|
* @param allowedIPs - Array of allowed IP patterns from security.ipAllowList
|
|
* @param blockedIPs - Array of blocked IP patterns from security.ipBlockList
|
|
* @returns true if IP is authorized, false if blocked
|
|
*/
|
|
public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean {
|
|
// Skip IP validation if allowedIPs is empty
|
|
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;
|
|
|
|
// Normalize the pattern IPs for consistent comparison
|
|
const expandedPatterns = patterns.flatMap(normalizeIP);
|
|
|
|
// Check for any match between normalized IP variants and patterns
|
|
return normalizedIPVariants.some((ipVariant) =>
|
|
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if IP should be allowed considering connection rate and max connections
|
|
* @returns Object with result and reason
|
|
*/
|
|
public validateIP(ip: string): { allowed: boolean; reason?: string } {
|
|
// Check connection count limit
|
|
if (
|
|
this.smartProxy.settings.maxConnectionsPerIP &&
|
|
this.getConnectionCountByIP(ip) >= this.smartProxy.settings.maxConnectionsPerIP
|
|
) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Maximum connections per IP (${this.smartProxy.settings.maxConnectionsPerIP}) exceeded`
|
|
};
|
|
}
|
|
|
|
// Check connection rate limit
|
|
if (
|
|
this.smartProxy.settings.connectionRateLimitPerMinute &&
|
|
!this.checkConnectionRate(ip)
|
|
) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Connection rate limit (${this.smartProxy.settings.connectionRateLimitPerMinute}/min) exceeded`
|
|
};
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Clears all IP tracking data (for shutdown)
|
|
*/
|
|
public clearIPTracking(): void {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
this.cleanupInterval = null;
|
|
}
|
|
this.connectionsByIP.clear();
|
|
this.connectionRateByIP.clear();
|
|
}
|
|
|
|
/**
|
|
* Start periodic cleanup of expired data
|
|
*/
|
|
private startPeriodicCleanup(): void {
|
|
this.cleanupInterval = setInterval(() => {
|
|
this.performCleanup();
|
|
}, 60000); // Run every minute
|
|
|
|
// Unref the timer so it doesn't keep the process alive
|
|
if (this.cleanupInterval.unref) {
|
|
this.cleanupInterval.unref();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform cleanup of expired rate limits and empty IP entries
|
|
*/
|
|
private performCleanup(): void {
|
|
const now = Date.now();
|
|
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);
|
|
|
|
if (validTimestamps.length === 0) {
|
|
// No valid timestamps, remove the IP entry
|
|
this.connectionRateByIP.delete(ip);
|
|
cleanedRateLimits++;
|
|
} else if (validTimestamps.length < timestamps.length) {
|
|
// Some timestamps expired, update with valid ones
|
|
this.connectionRateByIP.set(ip, validTimestamps);
|
|
}
|
|
}
|
|
|
|
// Clean up IPs with no active connections
|
|
for (const [ip, connections] of this.connectionsByIP.entries()) {
|
|
if (connections.size === 0) {
|
|
this.connectionsByIP.delete(ip);
|
|
cleanedIPs++;
|
|
}
|
|
}
|
|
|
|
// Log cleanup stats if anything was cleaned
|
|
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
|
|
if (this.smartProxy.settings.enableDetailedLogging) {
|
|
connectionLogDeduplicator.log(
|
|
'ip-cleanup',
|
|
'debug',
|
|
'IP tracking cleanup completed',
|
|
{
|
|
cleanedRateLimits,
|
|
cleanedIPs,
|
|
remainingIPs: this.connectionsByIP.size,
|
|
remainingRateLimits: this.connectionRateByIP.size,
|
|
component: 'security-manager'
|
|
},
|
|
'periodic-cleanup'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} |