Files
smartnetwork/ts/smartnetwork.classes.ipintelligence.ts

543 lines
18 KiB
TypeScript
Raw Normal View History

import * as plugins from './smartnetwork.plugins.js';
import { getLogger } from './logging.js';
// MaxMind types re-exported from mmdb-lib via maxmind
import type { CityResponse, AsnResponse, Reader } from 'maxmind';
/**
* 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<CityResponse> | null = null;
private asnReader: Reader<AsnResponse> | null = null;
private lastFetchTime = 0;
private refreshPromise: Promise<void> | null = null;
// RDAP bootstrap cache
private bootstrapEntries: IBootstrapEntry[] | null = null;
private bootstrapPromise: Promise<void> | 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<IIpIntelligenceResult> {
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<void> {
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> {
try {
const reversed = ip.split('.').reverse().join('.');
const queryName = `${reversed}.origin.asn.cymru.com`;
const 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;
}
}
// ─── MaxMind GeoLite2 Subsystem ─────────────────────────────────────
/**
* Ensure MMDB readers are initialized. Downloads on first call,
* triggers background refresh if stale.
*/
private async ensureReaders(): Promise<void> {
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<void> {
const [cityBuffer, asnBuffer] = await Promise.all([
this.fetchBuffer(CITY_MMDB_URL),
this.fetchBuffer(ASN_MMDB_URL),
]);
this.cityReader = new plugins.maxmind.Reader<CityResponse>(cityBuffer);
this.asnReader = new plugins.maxmind.Reader<AsnResponse>(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<Buffer> {
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
try {
const cityResult = this.cityReader.get(ip);
if (cityResult) {
country = cityResult.country?.names?.en || null;
countryCode = cityResult.country?.iso_code || null;
city = cityResult.city?.names?.en || null;
latitude = cityResult.location?.latitude ?? null;
longitude = cityResult.location?.longitude ?? null;
accuracyRadius = cityResult.location?.accuracy_radius ?? null;
timezone = cityResult.location?.time_zone || null;
}
} 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
);
}
}