import LRU from 'lru-cache'; import { normalizeIp } from './util'; export type OnTrackFn = (type: 'ip' | 'subnet' | 'host', key: string, ip?: string) => void; export type MetricsOptions = { historySize?: number; maxAge?: number; minHostRate?: number; maxHostRate?: number; maxHostRatio?: number; minIpRate?: number; maxIpRate?: number; maxIpRateHostViolation?: number; minSubnetRate?: number; maxSubnetRate?: number; maxSubnetRateHostViolation?: number; minSubnetUniqueIps?: number; hostWhitelist?: Set; ipWhitelist?: Set; subnetWhitelist?: Set; onTrack?: OnTrackFn; } export enum ActorStatus { Good = 200, Whitelisted = 300, Bad = 400 } export enum BadActorType { badHost = 'badHost', hostViolation = 'hostViolation', badIp = 'badIp', badSubnet = 'badSubnet', userLag = 'userLag' } export type CacheItem = { id: string; history: Array; // time rate: number; } export const DEFAULT_HISTORY_SIZE: number = 200; // 0.5% hit rate enough to reside in LRU export const DEFAULT_MAX_AGE: number = 1000 * 10; // 10s is generally more than sufficient history export const DEFAULT_MIN_HOST_RATE: number = 20; export const DEFAULT_MAX_HOST_RATE: number = 40; export const DEFAULT_MAX_HOST_RATIO: number = 0; // disabled export const DEFAULT_MIN_IP_RATE: number = 0; // disabled export const DEFAULT_MAX_IP_RATE: number = 0; // disabled export const DEFAULT_MAX_IP_RATE_BUSY_HOST: number = 0; // disabled export const DEFAULT_MIN_SUBNET_RATE: number = 0; // disabled export const DEFAULT_MAX_SUBNET_RATE: number = 0; // disabled export const DEFAULT_MAX_SUBNET_RATE_BUSY_HOST: number = 0; // disabled export const DEFAULT_MIN_SUBNET_UNIQUE_IPS: number = 1; // standalone Metrics default; ConnectQOS uses 50% of subnet size export const DEFAULT_HOST_WHITELIST = ['localhost']; export const DEFAULT_IP_WHITELIST = []; export const DEFAULT_SUBNET_WHITELIST = []; export class Metrics { constructor(opts?: MetricsOptions) { const { historySize = DEFAULT_HISTORY_SIZE, maxAge = DEFAULT_MAX_AGE, minHostRate = DEFAULT_MIN_HOST_RATE, maxHostRate = DEFAULT_MAX_HOST_RATE, maxHostRatio = DEFAULT_MAX_HOST_RATIO, minIpRate = DEFAULT_MIN_IP_RATE, maxIpRate = DEFAULT_MAX_IP_RATE, maxIpRateHostViolation = DEFAULT_MAX_IP_RATE_BUSY_HOST, minSubnetRate = DEFAULT_MIN_SUBNET_RATE, maxSubnetRate = DEFAULT_MAX_SUBNET_RATE, maxSubnetRateHostViolation = DEFAULT_MAX_SUBNET_RATE_BUSY_HOST, minSubnetUniqueIps = DEFAULT_MIN_SUBNET_UNIQUE_IPS, hostWhitelist = new Set(DEFAULT_HOST_WHITELIST), ipWhitelist = new Set(DEFAULT_IP_WHITELIST), subnetWhitelist = new Set(DEFAULT_SUBNET_WHITELIST), onTrack } = (opts || {} as MetricsOptions); if (minHostRate > maxHostRate) throw new Error(`${minHostRate} minHostRate cannot exceed ${maxHostRate} maxHostRate`) if (minIpRate > maxIpRate) throw new Error(`${minIpRate} minIpRate cannot exceed ${maxIpRate} maxIpRate`) if (minSubnetRate > maxSubnetRate) throw new Error(`${minSubnetRate} minSubnetRate cannot exceed ${maxSubnetRate} maxSubnetRate`) const lruOptions: LRU.Options = { max: historySize, //ttl: maxAge, // do NOT use ttl due to performance and we handle stale purges allowStale: false, updateAgeOnGet: true, updateAgeOnHas: false }; this.#hosts = new LRU(lruOptions); this.#ips = new LRU(lruOptions); this.#subnets = new LRU(lruOptions); this.#subnetIpTimestamps = new LRU({ max: historySize, allowStale: false, updateAgeOnGet: true, updateAgeOnHas: false }); this.#historySize = historySize; this.#maxAge = maxAge; this.#minHostRate = minHostRate; this.#maxHostRate = maxHostRate; this.#minHostRequests = Math.ceil(minHostRate * (maxAge/1000)); this.#maxHostRatio = Math.max(Math.min(maxHostRatio, 0.9), 0); // 90% is very high, but this cap is just to prevent invalid ratios this.#hostRatioMaxCount = Math.ceil(this.#maxHostRatio * 100 * 10); // 10x requests compared to host ratio (10% * 10 = 100) this.#minIpRate = minIpRate; this.#maxIpRate = maxIpRate; this.#maxIpRateHostViolation = maxIpRateHostViolation; this.#minIpRequests = Math.ceil(minIpRate * (maxAge/1000)); this.#minSubnetRate = minSubnetRate; this.#maxSubnetRate = maxSubnetRate; this.#maxSubnetRateHostViolation = maxSubnetRateHostViolation; this.#minSubnetRequests = Math.ceil(minSubnetRate * (maxAge/1000)); this.#minSubnetUniqueIps = Math.max(1, Math.round(minSubnetUniqueIps)); this.#hostWhitelist = hostWhitelist; this.#ipWhitelist = ipWhitelist; this.#subnetWhitelist = subnetWhitelist; this.#onTrack = onTrack; } #hosts: LRU; #ips: LRU; #subnets: LRU; #subnetIpTimestamps: LRU>; #historySize: number; #maxAge: number; #minHostRate: number; #maxHostRate: number; #minHostRequests: number; #maxHostRatio: number; #hostRatioMaxCount: number; #hostRatioRequestHistory: Array = []; #hostRatioViolations = new Set(); #hostRatioCounts = new Map(); #minIpRate: number; #maxIpRate: number; #maxIpRateHostViolation: number; #minIpRequests: number; #minSubnetRate: number; #maxSubnetRate: number; #maxSubnetRateHostViolation: number; #minSubnetRequests: number; #minSubnetUniqueIps: number; #hostWhitelist: Set; #ipWhitelist: Set; #subnetWhitelist: Set; #onTrack?: OnTrackFn; get hosts(): LRU { return this.#hosts; } get ips(): LRU { return this.#ips; } get subnets(): LRU { return this.#subnets; } get minHostRate(): number { return this.#minHostRate; } get maxHostRate(): number { return this.#maxHostRate; } get maxHostRatio(): number { return this.#maxHostRatio; } get hostRatioViolations(): Set { return this.#hostRatioViolations; } get minIpRate(): number { return this.#minIpRate; } get maxIpRate(): number { return this.#maxIpRate; } get maxIpRateHostViolation(): number { return this.#maxIpRateHostViolation; } get minSubnetRate(): number { return this.#minSubnetRate; } get maxSubnetRate(): number { return this.#maxSubnetRate; } get maxSubnetRateHostViolation(): number { return this.#maxSubnetRateHostViolation; } get minSubnetUniqueIps(): number { return this.#minSubnetUniqueIps; } get historySize(): number { return this.#historySize; } get maxAge(): number { return this.#maxAge; } get hostWhitelist(): Set { return this.#hostWhitelist; } get ipWhitelist(): Set { return this.#ipWhitelist; } get subnetWhitelist(): Set { return this.#subnetWhitelist; } getHostInfo(source: string): ActorStatus|CacheItem|undefined { return getInfo(source, { lru: this.#hosts, whitelist: this.#hostWhitelist, minRequests: this.#minHostRequests, maxAge: this.#maxAge }); } trackHost(source: string, cache?: CacheItem): CacheItem|undefined { if (this.#hostWhitelist.has(source)) return; this.#onTrack?.('host', source); if (this.#maxHostRatio) { // only track if ratio limits enabled (source is never whitelisted here — guarded above) this.#hostRatioCounts.set(source, (this.#hostRatioCounts.get(source) || 0) + 1); const now = Date.now(); this.#hostRatioRequestHistory.push(now); const cutoff = now - 60000; // 1 minute history let staleCount = 0; while (staleCount < this.#hostRatioRequestHistory.length && this.#hostRatioRequestHistory[staleCount] < cutoff) staleCount++; if (staleCount) this.#hostRatioRequestHistory.splice(0, staleCount); if (this.#hostRatioRequestHistory.length >= this.#hostRatioMaxCount) { // check for violations once we have sufficient history const maxCount = Math.round(this.#hostRatioRequestHistory.length * this.#maxHostRatio); this.#hostRatioViolations = [...this.#hostRatioCounts.entries()] .reduce((violations, [source, count]) => { if (count > maxCount) violations.add(source); return violations; }, new Set()); this.#hostRatioCounts.clear(); this.#hostRatioRequestHistory = []; } } return track(source, { lru: this.#hosts, cache, minRate: this.#minHostRate }); } getIpInfo(source: string): ActorStatus|CacheItem|undefined { return getInfo(source, { lru: this.#ips, whitelist: this.#ipWhitelist, minRequests: this.#minIpRequests, maxAge: this.#maxAge }); } trackIp(source: string, cache?: CacheItem): CacheItem|undefined { if (this.#ipWhitelist.has(source)) return; this.#onTrack?.('ip', source); return track(source, { lru: this.#ips, cache, minRate: this.#minIpRate }); } getSubnetInfo(source: string): ActorStatus|CacheItem|undefined { return getInfo(source, { lru: this.#subnets, whitelist: this.#subnetWhitelist, minRequests: this.#minSubnetRequests, maxAge: this.#maxAge }); } trackSubnet(source: string, cache?: CacheItem, ip?: string): CacheItem|undefined { if (this.#subnetWhitelist.has(source)) return; this.#onTrack?.('subnet', source, ip); // Skip IPv6 keys: they are the IP itself so unique-IP count is always 1 and the // gate is not applied to them; tracking would add work without any benefit. if (ip && this.#minSubnetUniqueIps > 1 && this.#minSubnetRate > 0 && !source.includes(':')) { const now = Date.now(); let ipMap = this.#subnetIpTimestamps.get(source); if (!ipMap) { ipMap = new Map(); this.#subnetIpTimestamps.set(source, ipMap); } // Prune stale entries before checking the cap so stale IPs don't hold // slots that prevent new IPs from being tracked. const cutoff = now - this.#maxAge; for (const [existingIp, ts] of ipMap) { if (ts < cutoff) ipMap.delete(existingIp); } // Normalize IPv4-mapped IPv6 (::ffff:a.b.c.d → a.b.c.d) so the same physical // client is not double-counted when observed via both address forms. const normalizedIp = normalizeIp(ip); // Refresh already-tracked IPs unconditionally; only add new IPs when below // the cap so the map (and the prune walk above) stay bounded at minSubnetUniqueIps. if (ipMap.has(normalizedIp) || ipMap.size < this.#minSubnetUniqueIps) { ipMap.set(normalizedIp, now); } } return track(source, { lru: this.#subnets, cache, minRate: this.#minSubnetRate }); } getSubnetUniqueIpCount(source: string): number { const ipMap = this.#subnetIpTimestamps.get(source); if (!ipMap) return 0; const cutoff = Date.now() - this.#maxAge; let count = 0; for (const [ip, ts] of ipMap) { if (ts >= cutoff) { count++; } else { ipMap.delete(ip); } } if (ipMap.size === 0) this.#subnetIpTimestamps.delete(source); return count; } } export type GetInfoOptions = { lru: LRU, whitelist: Set, minRequests: number, maxAge: number } function getInfo(source: string, { lru, whitelist, minRequests, maxAge }: GetInfoOptions): ActorStatus|CacheItem|undefined { if (!minRequests) return ActorStatus.Good; // if monitoring is disabled treat as Good // reserved to indicate will never be a bad actor if (whitelist.has(source)) return ActorStatus.Whitelisted; let cache: CacheItem|undefined = lru.get(source); if (cache) { // always precompute `rate` & `ratio` based on NOW // update rate const now = Date.now(); const expiredAt = now - maxAge; // always remove stale history before calculating rate let expiredCount; // slightly faster than shifting for (expiredCount = 0; expiredCount < cache.history.length; expiredCount++) { if (cache.history[expiredCount] >= expiredAt) break; } if (expiredCount) { cache.history = cache.history.slice(expiredCount); } if (cache.history.length < minRequests) { cache.rate = 0; // insufficient history to measure } else { const eldest = cache.history[0]; // default to 1ms to avoid divide by zero errors since we do have adequate history const age = (now - eldest) || 1; cache.rate = ((cache.history.length / age) * 1000); } } return cache; } export type TrackOptions = { lru: LRU, cache?: CacheItem, minRate: number } function track(source: string, options: TrackOptions): CacheItem|undefined { const { lru, minRate } = options; let { cache } = options; if (!minRate) return void 0; // tracking disabled if (!cache) cache = lru.get(source); // if not supplied grab from lru if (!cache) { // if not in LRU create it cache = { id: source, history: new Array(), rate: 0 }; lru.set(source, cache); } cache.history.push(Date.now()); // head=eldest, tail=newest return cache; }