import * as plugins from './smartnetwork.plugins.js'; import { getLogger } from './logging.js'; // MaxMind types re-exported from mmdb-lib via maxmind import type { AsnResponse, Reader, Response } from 'maxmind'; /** * The @ip-location-db MMDB files use a flat schema instead of the standard MaxMind nested format. */ interface IIpLocationDbCityRecord { city?: string; country_code?: string; latitude?: number; longitude?: number; postcode?: string; state1?: string; state2?: string; timezone?: string; } /** * Unified result from all IP intelligence layers */ export interface IIpIntelligenceResult { // ASN (Team Cymru primary, MaxMind fallback) asn: number | null; asnOrg: string | null; // Registration (RDAP) registrantOrg: string | null; registrantCountry: string | null; networkRange: string | null; networkCidrs: string[] | null; abuseContact: string | null; // Geolocation (MaxMind GeoLite2 City) country: string | null; countryCode: string | null; city: string | null; latitude: number | null; longitude: number | null; accuracyRadius: number | null; timezone: string | null; } /** * Options for IpIntelligence */ export interface IIpIntelligenceOptions { /** Max age (ms) before triggering background MMDB refresh. Default: 7 days */ dbMaxAge?: number; /** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */ timeout?: number; /** * Optional injected smartdns client. When provided, IpIntelligence will * not create or destroy its own client (the owner — typically SmartNetwork — * manages lifecycle). When omitted, a short-lived client is created per * Team Cymru lookup and destroyed in finally. */ dnsClient?: InstanceType; } // CDN URLs for GeoLite2 MMDB files (served via jsDelivr from npm packages) const CITY_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb'; const ASN_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-asn-mmdb/geolite2-asn-ipv4.mmdb'; // IANA bootstrap for RDAP const IANA_BOOTSTRAP_IPV4_URL = 'https://data.iana.org/rdap/ipv4.json'; const DEFAULT_DB_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days const DEFAULT_TIMEOUT = 5000; /** * Parsed IANA bootstrap entry: a CIDR prefix mapped to an RDAP base URL */ interface IBootstrapEntry { prefix: string; prefixNum: number; // numeric representation of the network address maskBits: number; baseUrl: string; } interface IRdapNetworkInfo { networkRange: string | null; networkCidrs: string[] | null; } /** * IpIntelligence provides IP address intelligence by combining three data sources: * - RDAP (direct to RIRs) for registration/org data * - Team Cymru DNS for ASN * - MaxMind GeoLite2 (in-memory MMDB) for geolocation */ export class IpIntelligence { private readonly logger = getLogger(); private readonly dbMaxAge: number; private readonly timeout: number; // MaxMind readers (lazily initialized) private cityReader: Reader | null = null; private asnReader: Reader | null = null; private lastFetchTime = 0; private refreshPromise: Promise | null = null; // RDAP bootstrap cache private bootstrapEntries: IBootstrapEntry[] | null = null; private bootstrapPromise: Promise | null = null; // Optional injected smartdns client (shared by SmartNetwork) private readonly sharedDnsClient: InstanceType | null; constructor(options?: IIpIntelligenceOptions) { this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE; this.timeout = options?.timeout ?? DEFAULT_TIMEOUT; this.sharedDnsClient = options?.dnsClient ?? null; } /** * Get comprehensive IP intelligence for the given IP address. * Runs RDAP, Team Cymru DNS, and MaxMind lookups in parallel. */ public async getIntelligence(ip: string): Promise { const result: IIpIntelligenceResult = { asn: null, asnOrg: null, registrantOrg: null, registrantCountry: null, networkRange: null, networkCidrs: null, abuseContact: null, country: null, countryCode: null, city: null, latitude: null, longitude: null, accuracyRadius: null, timezone: null, }; // Run all three layers in parallel const [rdapResult, cymruResult, maxmindResult] = await Promise.allSettled([ this.queryRdap(ip), this.queryTeamCymru(ip), this.queryMaxMind(ip), ]); // Merge RDAP results if (rdapResult.status === 'fulfilled' && rdapResult.value) { const rdap = rdapResult.value; result.registrantOrg = rdap.registrantOrg; result.registrantCountry = rdap.registrantCountry; result.networkRange = rdap.networkRange; result.networkCidrs = rdap.networkCidrs; result.abuseContact = rdap.abuseContact; } // Merge Team Cymru results (primary for ASN) if (cymruResult.status === 'fulfilled' && cymruResult.value) { const cymru = cymruResult.value; result.asn = cymru.asn; } // Merge MaxMind results if (maxmindResult.status === 'fulfilled' && maxmindResult.value) { const mm = maxmindResult.value; result.country = mm.country; result.countryCode = mm.countryCode; result.city = mm.city; result.latitude = mm.latitude; result.longitude = mm.longitude; result.accuracyRadius = mm.accuracyRadius; result.timezone = mm.timezone; // Use MaxMind ASN as fallback if Team Cymru failed if (result.asn === null && mm.asn !== null) { result.asn = mm.asn; } if (mm.asnOrg) { result.asnOrg = mm.asnOrg; } } // If we got ASN from Team Cymru but not org, and MaxMind didn't provide org either, // the asnOrg remains null (we don't do an additional lookup) return result; } // ─── RDAP Subsystem ───────────────────────────────────────────────── /** * Load and cache the IANA RDAP bootstrap file */ private async ensureBootstrap(): Promise { if (this.bootstrapEntries) return; if (this.bootstrapPromise) { await this.bootstrapPromise; return; } this.bootstrapPromise = (async () => { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(IANA_BOOTSTRAP_IPV4_URL, { signal: controller.signal, headers: { 'User-Agent': '@push.rocks/smartnetwork' }, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json() as { services: [string[], string[]][] }; const entries: IBootstrapEntry[] = []; for (const [prefixes, urls] of data.services) { const baseUrl = urls[0]; // first URL is preferred for (const prefix of prefixes) { const [network, bits] = prefix.split('/'); entries.push({ prefix, prefixNum: this.ipToNumber(network), maskBits: parseInt(bits, 10), baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash }); } } // Sort by mask bits descending for longest-prefix match entries.sort((a, b) => b.maskBits - a.maskBits); this.bootstrapEntries = entries; } finally { clearTimeout(timeoutId); } } catch (err: any) { this.logger.debug?.(`Failed to load RDAP bootstrap: ${err.message}`); this.bootstrapEntries = []; // empty = all RDAP lookups will skip } })(); await this.bootstrapPromise; this.bootstrapPromise = null; } /** * Find the RDAP base URL for a given IP via longest-prefix match */ private matchRir(ip: string): string | null { if (!this.bootstrapEntries || this.bootstrapEntries.length === 0) return null; const ipNum = this.ipToNumber(ip); for (const entry of this.bootstrapEntries) { const mask = (0xFFFFFFFF << (32 - entry.maskBits)) >>> 0; if ((ipNum & mask) === (entry.prefixNum & mask)) { return entry.baseUrl; } } return null; } /** * Query RDAP for registration data */ private async queryRdap(ip: string): Promise<{ registrantOrg: string | null; registrantCountry: string | null; networkRange: string | null; networkCidrs: string[] | null; abuseContact: string | null; } | null> { await this.ensureBootstrap(); const baseUrl = this.matchRir(ip); if (!baseUrl) return null; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(`${baseUrl}/ip/${ip}`, { signal: controller.signal, headers: { 'Accept': 'application/rdap+json', 'User-Agent': '@push.rocks/smartnetwork', }, }); if (!response.ok) return null; const data = await response.json() as any; let registrantOrg: string | null = null; let registrantCountry: string | null = data.country || null; let abuseContact: string | null = null; const { networkRange, networkCidrs } = this.parseRdapNetworkInfo(data); // Parse entities if (data.entities && Array.isArray(data.entities)) { for (const entity of data.entities) { const roles: string[] = entity.roles || []; if (roles.includes('registrant') || roles.includes('administrative')) { const orgName = this.extractVcardFn(entity); if (orgName) registrantOrg = orgName; // Try to get country from registrant address if not at top level if (!registrantCountry) { registrantCountry = this.extractVcardCountry(entity); } } if (roles.includes('abuse')) { abuseContact = this.extractVcardEmail(entity); // Check nested entities for abuse contact if (!abuseContact && entity.entities) { for (const subEntity of entity.entities) { const subRoles: string[] = subEntity.roles || []; if (subRoles.includes('abuse')) { abuseContact = this.extractVcardEmail(subEntity); if (abuseContact) break; } } } } } } return { registrantOrg, registrantCountry, networkRange, networkCidrs, abuseContact }; } catch (err: any) { this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`); return null; } finally { clearTimeout(timeoutId); } } private parseRdapNetworkInfo(data: any): IRdapNetworkInfo { const cidrs = this.extractRdapCidrs(data); if (cidrs.length > 0) { return { networkRange: cidrs[0], networkCidrs: cidrs, }; } if (typeof data.startAddress === 'string' && typeof data.endAddress === 'string') { const rangeCidrs = this.ipv4RangeToCidrs(data.startAddress, data.endAddress); return { networkRange: rangeCidrs.length === 1 ? rangeCidrs[0] : `${data.startAddress} - ${data.endAddress}`, networkCidrs: rangeCidrs.length > 0 ? rangeCidrs : null, }; } return { networkRange: null, networkCidrs: null }; } private extractRdapCidrs(data: any): string[] { if (!Array.isArray(data.cidr0_cidrs)) return []; return data.cidr0_cidrs .map((cidr: any) => { const prefix = cidr?.v4prefix || cidr?.v6prefix; const length = Number(cidr?.length); if (typeof prefix !== 'string' || !Number.isInteger(length)) return null; return `${prefix}/${length}`; }) .filter(Boolean) as string[]; } /** * Extract the 'fn' (formatted name) from an entity's vcardArray */ private extractVcardFn(entity: any): string | null { if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null; const properties = entity.vcardArray[1]; if (!Array.isArray(properties)) return null; for (const prop of properties) { if (Array.isArray(prop) && prop[0] === 'fn') { return prop[3] || null; } } return null; } /** * Extract email from an entity's vcardArray */ private extractVcardEmail(entity: any): string | null { if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null; const properties = entity.vcardArray[1]; if (!Array.isArray(properties)) return null; for (const prop of properties) { if (Array.isArray(prop) && prop[0] === 'email') { return prop[3] || null; } } return null; } /** * Extract country from an entity's vcardArray address field */ private extractVcardCountry(entity: any): string | null { if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null; const properties = entity.vcardArray[1]; if (!Array.isArray(properties)) return null; for (const prop of properties) { if (Array.isArray(prop) && prop[0] === 'adr') { // The label parameter often contains the full address with country at the end const label = prop[1]?.label; if (typeof label === 'string') { const lines = label.split('\n'); const lastLine = lines[lines.length - 1]?.trim(); if (lastLine && lastLine.length > 1) return lastLine; } // Also check the structured value (7-element array, last element is country) const value = prop[3]; if (Array.isArray(value) && value.length >= 7 && value[6]) { return value[6]; } } } return null; } // ─── Team Cymru DNS Subsystem ─────────────────────────────────────── /** * Query Team Cymru DNS for ASN information. * Query format: reversed.ip.origin.asn.cymru.com TXT * Response: "ASN | prefix | CC | rir | date" */ private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> { const external = this.sharedDnsClient !== null; let dnsClient = this.sharedDnsClient; try { const reversed = ip.split('.').reverse().join('.'); const queryName = `${reversed}.origin.asn.cymru.com`; if (!dnsClient) { dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({ strategy: 'prefer-system', allowDohFallback: true, timeoutMs: this.timeout, }); } const records = await dnsClient.getRecordsTxt(queryName); if (!records || records.length === 0) return null; // Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11" const txt = records[0].value || (records[0] as any).data; if (!txt) return null; const parts = txt.split('|').map((s: string) => s.trim()); if (parts.length < 3) return null; const asn = parseInt(parts[0], 10); if (isNaN(asn)) return null; return { asn, prefix: parts[1] || '', country: parts[2] || '', }; } catch (err: any) { this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`); return null; } finally { // Only destroy clients we created ourselves; leave injected ones alone. if (!external && dnsClient) { dnsClient.destroy(); } } } // ─── MaxMind GeoLite2 Subsystem ───────────────────────────────────── /** * Ensure MMDB readers are initialized. Downloads on first call, * triggers background refresh if stale. */ private async ensureReaders(): Promise { if (this.cityReader && this.asnReader) { // Check if refresh needed if (Date.now() - this.lastFetchTime > this.dbMaxAge && !this.refreshPromise) { this.refreshPromise = this.downloadAndInitReaders() .catch((err) => this.logger.debug?.(`Background MMDB refresh failed: ${err.message}`)) .finally(() => { this.refreshPromise = null; }); } return; } // First time: blocking download if (this.refreshPromise) { await this.refreshPromise; return; } this.refreshPromise = this.downloadAndInitReaders(); await this.refreshPromise; this.refreshPromise = null; } /** * Download MMDB files from CDN and create Reader instances */ private async downloadAndInitReaders(): Promise { const [cityBuffer, asnBuffer] = await Promise.all([ this.fetchBuffer(CITY_MMDB_URL), this.fetchBuffer(ASN_MMDB_URL), ]); this.cityReader = new plugins.maxmind.Reader(cityBuffer); this.asnReader = new plugins.maxmind.Reader(asnBuffer); this.lastFetchTime = Date.now(); this.logger.info?.('MaxMind MMDB databases loaded into memory'); } /** * Fetch a URL and return the response as a Buffer */ private async fetchBuffer(url: string): Promise { const response = await fetch(url, { headers: { 'User-Agent': '@push.rocks/smartnetwork' }, }); if (!response.ok) { throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } /** * Query MaxMind for geo + ASN data */ private async queryMaxMind(ip: string): Promise<{ country: string | null; countryCode: string | null; city: string | null; latitude: number | null; longitude: number | null; accuracyRadius: number | null; timezone: string | null; asn: number | null; asnOrg: string | null; } | null> { try { await this.ensureReaders(); } catch (err: any) { this.logger.debug?.(`Failed to initialize MaxMind readers: ${err.message}`); return null; } if (!this.cityReader || !this.asnReader) return null; let country: string | null = null; let countryCode: string | null = null; let city: string | null = null; let latitude: number | null = null; let longitude: number | null = null; let accuracyRadius: number | null = null; let timezone: string | null = null; let asn: number | null = null; let asnOrg: string | null = null; // City lookup — @ip-location-db uses flat schema: city, country_code, latitude, longitude, etc. try { const cityResult = this.cityReader.get(ip); if (cityResult) { countryCode = cityResult.country_code || null; city = cityResult.city || null; latitude = cityResult.latitude ?? null; longitude = cityResult.longitude ?? null; timezone = cityResult.timezone || null; // @ip-location-db does not include country name or accuracy_radius // We leave country null (countryCode is available) } } catch (err: any) { this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`); } // ASN lookup try { const asnResult = this.asnReader.get(ip); if (asnResult) { asn = asnResult.autonomous_system_number ?? null; asnOrg = asnResult.autonomous_system_organization || null; } } catch (err: any) { this.logger.debug?.(`MaxMind ASN lookup failed for ${ip}: ${err.message}`); } return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg }; } // ─── Utilities ────────────────────────────────────────────────────── /** * Convert an IPv4 address string to a 32-bit unsigned number */ private ipToNumber(ip: string): number { const parts = ip.split('.'); return ( ((parseInt(parts[0], 10) << 24) | (parseInt(parts[1], 10) << 16) | (parseInt(parts[2], 10) << 8) | parseInt(parts[3], 10)) >>> 0 ); } private ipv4RangeToCidrs(startIp: string, endIp: string): string[] { const start = this.ipv4ToBigInt(startIp); const end = this.ipv4ToBigInt(endIp); if (start === undefined || end === undefined || start > end) return []; const cidrs: string[] = []; let current = start; while (current <= end) { let maxBlockSize = current === 0n ? 1n << 32n : current & -current; const remaining = end - current + 1n; while (maxBlockSize > remaining) { maxBlockSize = maxBlockSize / 2n; } const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize); cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`); current += maxBlockSize; } return cidrs; } private ipv4ToBigInt(ip: string): bigint | undefined { const parts = ip.trim().split('.'); if (parts.length !== 4) return undefined; let result = 0n; for (const part of parts) { if (!/^\d+$/.test(part)) return undefined; const number = Number(part); if (!Number.isInteger(number) || number < 0 || number > 255) return undefined; result = (result * 256n) + BigInt(number); } return result; } private numberToIpv4(value: bigint): string { return [ Number((value >> 24n) & 255n), Number((value >> 16n) & 255n), Number((value >> 8n) & 255n), Number(value & 255n), ].join('.'); } private powerOfTwoExponent(value: bigint): number { let exponent = 0; let remaining = value; while (remaining > 1n) { remaining >>= 1n; exponent++; } return exponent; } }