543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
|
|
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
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|