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; 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; } // 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; } /** * 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; constructor(options?: IIpIntelligenceOptions) { this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE; this.timeout = options?.timeout ?? DEFAULT_TIMEOUT; } /** * 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, 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.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; 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; // Parse network range let networkRange: string | null = null; if (data.cidr0_cidrs && data.cidr0_cidrs.length > 0) { const cidr = data.cidr0_cidrs[0]; networkRange = `${cidr.v4prefix || cidr.v6prefix}/${cidr.length}`; } else if (data.startAddress && data.endAddress) { networkRange = `${data.startAddress} - ${data.endAddress}`; } // 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, abuseContact }; } catch (err: any) { this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`); return null; } finally { clearTimeout(timeoutId); } } /** * 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> { let dnsClient: InstanceType | null = null; try { const reversed = ip.split('.').reverse().join('.'); const queryName = `${reversed}.origin.asn.cymru.com`; 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 { if (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 ); } }