feat(network): add ProxyCheck IP intelligence

This commit is contained in:
dmg
2026-08-01 14:32:54 -04:00
parent 10554eeaff
commit 40abab7abc
18 changed files with 539 additions and 26 deletions
+119
View File
@@ -1,11 +1,31 @@
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;
}
export interface IpIntelligenceResult {
classification: IpClassification;
provider: string | null;
riskScore?: number | null;
location?: IpLocation;
network?: IpNetwork;
rawResponse?: Record<string, unknown>;
lookupError?: boolean;
}
export interface IpIntelligenceProvider {
@@ -18,6 +38,105 @@ export class NoopIpIntelligenceProvider implements IpIntelligenceProvider {
}
}
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 {
if (String(proxy).toLowerCase() !== "yes") return "clear";
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<IpIntelligenceResult> {
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<string, unknown>;
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<string, unknown>;
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),
},
rawResponse: root,
};
}
}
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;