import { isIP } from "node:net"; import ipaddr from "ipaddr.js"; export type IpClassification = "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor"; export interface IpLocation { city: string | null; region: string | null; country: string | null; countryCode: string | null; latitude: number | null; longitude: number | null; timezone: string | null; } export interface IpNetwork { asn: string | null; provider: string | null; connectionType?: string | null; proxy?: boolean | null; } export interface IpIntelligenceResult { classification: IpClassification; provider: string | null; riskScore?: number | null; location?: IpLocation; network?: IpNetwork; rawResponse?: Record; lookupError?: boolean; } export interface IpIntelligenceProvider { classify(ipAddress: string): Promise; } export class NoopIpIntelligenceProvider implements IpIntelligenceProvider { async classify(_ipAddress: string): Promise { return { classification: "unknown", provider: null }; } } function stringValue(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : null; } function numberValue(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); return null; } function proxyClassification(proxy: unknown, type: unknown): IpClassification { const normalizedProxy = String(proxy).toLowerCase(); if (normalizedProxy === "no") return "clear"; if (normalizedProxy !== "yes") return "unknown"; const normalizedType = String(type ?? "").toLowerCase(); if (normalizedType.includes("tor")) return "tor"; if (normalizedType.includes("vpn")) return "vpn"; if (normalizedType.includes("hosting") || normalizedType.includes("server")) return "hosting"; return "proxy"; } export class ProxyCheckProvider implements IpIntelligenceProvider { private readonly apiKey: string; private readonly request: typeof fetch; private readonly timeoutMs: number; constructor(options: { apiKey: string; request?: typeof fetch; timeoutMs?: number }) { this.apiKey = options.apiKey; this.request = options.request ?? fetch; this.timeoutMs = options.timeoutMs ?? 2_500; } async classify(ipAddress: string): Promise { if (!this.apiKey) throw new Error("ProxyCheck API key is not configured"); if (!isPublicIp(ipAddress)) throw new Error("ProxyCheck requires a public IP address"); const url = new URL(`https://proxycheck.io/v2/${encodeURIComponent(ipAddress)}`); url.searchParams.set("key", this.apiKey); url.searchParams.set("vpn", "1"); url.searchParams.set("asn", "1"); url.searchParams.set("risk", "1"); const response = await this.request(url.toString(), { headers: { accept: "application/json" }, signal: AbortSignal.timeout(this.timeoutMs), }); if (!response.ok) throw new Error(`ProxyCheck lookup failed (${response.status})`); const payload: unknown = await response.json(); if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new Error("ProxyCheck lookup failed: invalid response"); } const root = payload as Record; const details = root[ipAddress]; if (root.status !== "ok" || !details || typeof details !== "object" || Array.isArray(details)) { throw new Error("ProxyCheck lookup failed: service rejected the request"); } const data = details as Record; return { classification: proxyClassification(data.proxy, data.type), provider: "proxycheck", riskScore: numberValue(data.risk), location: { city: stringValue(data.city), region: stringValue(data.region), country: stringValue(data.country), countryCode: stringValue(data.isocode), latitude: numberValue(data.latitude), longitude: numberValue(data.longitude), timezone: stringValue(data.timezone), }, network: { asn: stringValue(data.asn), provider: stringValue(data.provider) ?? stringValue(data.organisation), connectionType: stringValue(data.type), proxy: String(data.proxy).toLowerCase() === "yes" ? true : String(data.proxy).toLowerCase() === "no" ? false : null, }, rawResponse: root, }; } } export function addressGroup(ipAddress: string) { const hostAddress = ipAddress.split("/", 1)[0] ?? ipAddress; if (!isIP(hostAddress)) return ipAddress; let address = ipaddr.parse(hostAddress); if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) { address = address.toIPv4Address(); } const prefixLength = address.kind() === "ipv4" ? 24 : 64; const bytes = address.toByteArray(); for (let bit = prefixLength; bit < bytes.length * 8; bit += 1) { const byteIndex = Math.floor(bit / 8); const bitMask = 1 << (7 - (bit % 8)); bytes[byteIndex] = (bytes[byteIndex] ?? 0) & ~bitMask; } return `${ipaddr.fromByteArray(bytes).toString()}/${prefixLength}`; } export function isPublicIp(ipAddress: string) { if (!isIP(ipAddress)) return false; let address = ipaddr.parse(ipAddress); if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) { address = address.toIPv4Address(); } return address.range() === "unicast"; } export function shouldBlockIpClassification( classification: IpClassification, blockHosting: boolean, ) { if (classification === "unknown") return true; if (["vpn", "proxy", "tor"].includes(classification)) return true; return classification === "hosting" && blockHosting; } export function getClientIp(headers: Headers, trustProxy: boolean) { if (!trustProxy) return null; const candidate = headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip")?.trim() || null; return candidate && isIP(candidate) ? candidate : null; }