@push.rocks/smartnetwork 4.4.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,561 @@
1
+ import * as plugins from './smartnetwork.plugins.js';
2
+ import { getLogger } from './logging.js';
3
+
4
+ // MaxMind types re-exported from mmdb-lib via maxmind
5
+ import type { AsnResponse, Reader, Response } from 'maxmind';
6
+
7
+ /**
8
+ * The @ip-location-db MMDB files use a flat schema instead of the standard MaxMind nested format.
9
+ */
10
+ interface IIpLocationDbCityRecord {
11
+ city?: string;
12
+ country_code?: string;
13
+ latitude?: number;
14
+ longitude?: number;
15
+ postcode?: string;
16
+ state1?: string;
17
+ state2?: string;
18
+ timezone?: string;
19
+ }
20
+
21
+ /**
22
+ * Unified result from all IP intelligence layers
23
+ */
24
+ export interface IIpIntelligenceResult {
25
+ // ASN (Team Cymru primary, MaxMind fallback)
26
+ asn: number | null;
27
+ asnOrg: string | null;
28
+
29
+ // Registration (RDAP)
30
+ registrantOrg: string | null;
31
+ registrantCountry: string | null;
32
+ networkRange: string | null;
33
+ abuseContact: string | null;
34
+
35
+ // Geolocation (MaxMind GeoLite2 City)
36
+ country: string | null;
37
+ countryCode: string | null;
38
+ city: string | null;
39
+ latitude: number | null;
40
+ longitude: number | null;
41
+ accuracyRadius: number | null;
42
+ timezone: string | null;
43
+ }
44
+
45
+ /**
46
+ * Options for IpIntelligence
47
+ */
48
+ export interface IIpIntelligenceOptions {
49
+ /** Max age (ms) before triggering background MMDB refresh. Default: 7 days */
50
+ dbMaxAge?: number;
51
+ /** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */
52
+ timeout?: number;
53
+ }
54
+
55
+ // CDN URLs for GeoLite2 MMDB files (served via jsDelivr from npm packages)
56
+ const CITY_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb';
57
+ const ASN_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-asn-mmdb/geolite2-asn-ipv4.mmdb';
58
+
59
+ // IANA bootstrap for RDAP
60
+ const IANA_BOOTSTRAP_IPV4_URL = 'https://data.iana.org/rdap/ipv4.json';
61
+
62
+ const DEFAULT_DB_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
63
+ const DEFAULT_TIMEOUT = 5000;
64
+
65
+ /**
66
+ * Parsed IANA bootstrap entry: a CIDR prefix mapped to an RDAP base URL
67
+ */
68
+ interface IBootstrapEntry {
69
+ prefix: string;
70
+ prefixNum: number; // numeric representation of the network address
71
+ maskBits: number;
72
+ baseUrl: string;
73
+ }
74
+
75
+ /**
76
+ * IpIntelligence provides IP address intelligence by combining three data sources:
77
+ * - RDAP (direct to RIRs) for registration/org data
78
+ * - Team Cymru DNS for ASN
79
+ * - MaxMind GeoLite2 (in-memory MMDB) for geolocation
80
+ */
81
+ export class IpIntelligence {
82
+ private readonly logger = getLogger();
83
+ private readonly dbMaxAge: number;
84
+ private readonly timeout: number;
85
+
86
+ // MaxMind readers (lazily initialized)
87
+ private cityReader: Reader<IIpLocationDbCityRecord & Response> | null = null;
88
+ private asnReader: Reader<AsnResponse> | null = null;
89
+ private lastFetchTime = 0;
90
+ private refreshPromise: Promise<void> | null = null;
91
+
92
+ // RDAP bootstrap cache
93
+ private bootstrapEntries: IBootstrapEntry[] | null = null;
94
+ private bootstrapPromise: Promise<void> | null = null;
95
+
96
+ constructor(options?: IIpIntelligenceOptions) {
97
+ this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
98
+ this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
99
+ }
100
+
101
+ /**
102
+ * Get comprehensive IP intelligence for the given IP address.
103
+ * Runs RDAP, Team Cymru DNS, and MaxMind lookups in parallel.
104
+ */
105
+ public async getIntelligence(ip: string): Promise<IIpIntelligenceResult> {
106
+ const result: IIpIntelligenceResult = {
107
+ asn: null,
108
+ asnOrg: null,
109
+ registrantOrg: null,
110
+ registrantCountry: null,
111
+ networkRange: null,
112
+ abuseContact: null,
113
+ country: null,
114
+ countryCode: null,
115
+ city: null,
116
+ latitude: null,
117
+ longitude: null,
118
+ accuracyRadius: null,
119
+ timezone: null,
120
+ };
121
+
122
+ // Run all three layers in parallel
123
+ const [rdapResult, cymruResult, maxmindResult] = await Promise.allSettled([
124
+ this.queryRdap(ip),
125
+ this.queryTeamCymru(ip),
126
+ this.queryMaxMind(ip),
127
+ ]);
128
+
129
+ // Merge RDAP results
130
+ if (rdapResult.status === 'fulfilled' && rdapResult.value) {
131
+ const rdap = rdapResult.value;
132
+ result.registrantOrg = rdap.registrantOrg;
133
+ result.registrantCountry = rdap.registrantCountry;
134
+ result.networkRange = rdap.networkRange;
135
+ result.abuseContact = rdap.abuseContact;
136
+ }
137
+
138
+ // Merge Team Cymru results (primary for ASN)
139
+ if (cymruResult.status === 'fulfilled' && cymruResult.value) {
140
+ const cymru = cymruResult.value;
141
+ result.asn = cymru.asn;
142
+ }
143
+
144
+ // Merge MaxMind results
145
+ if (maxmindResult.status === 'fulfilled' && maxmindResult.value) {
146
+ const mm = maxmindResult.value;
147
+ result.country = mm.country;
148
+ result.countryCode = mm.countryCode;
149
+ result.city = mm.city;
150
+ result.latitude = mm.latitude;
151
+ result.longitude = mm.longitude;
152
+ result.accuracyRadius = mm.accuracyRadius;
153
+ result.timezone = mm.timezone;
154
+
155
+ // Use MaxMind ASN as fallback if Team Cymru failed
156
+ if (result.asn === null && mm.asn !== null) {
157
+ result.asn = mm.asn;
158
+ }
159
+ if (mm.asnOrg) {
160
+ result.asnOrg = mm.asnOrg;
161
+ }
162
+ }
163
+
164
+ // If we got ASN from Team Cymru but not org, and MaxMind didn't provide org either,
165
+ // the asnOrg remains null (we don't do an additional lookup)
166
+
167
+ return result;
168
+ }
169
+
170
+ // ─── RDAP Subsystem ─────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Load and cache the IANA RDAP bootstrap file
174
+ */
175
+ private async ensureBootstrap(): Promise<void> {
176
+ if (this.bootstrapEntries) return;
177
+ if (this.bootstrapPromise) {
178
+ await this.bootstrapPromise;
179
+ return;
180
+ }
181
+
182
+ this.bootstrapPromise = (async () => {
183
+ try {
184
+ const controller = new AbortController();
185
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
186
+ try {
187
+ const response = await fetch(IANA_BOOTSTRAP_IPV4_URL, {
188
+ signal: controller.signal,
189
+ headers: { 'User-Agent': '@push.rocks/smartnetwork' },
190
+ });
191
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
192
+ const data = await response.json() as { services: [string[], string[]][] };
193
+
194
+ const entries: IBootstrapEntry[] = [];
195
+ for (const [prefixes, urls] of data.services) {
196
+ const baseUrl = urls[0]; // first URL is preferred
197
+ for (const prefix of prefixes) {
198
+ const [network, bits] = prefix.split('/');
199
+ entries.push({
200
+ prefix,
201
+ prefixNum: this.ipToNumber(network),
202
+ maskBits: parseInt(bits, 10),
203
+ baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
204
+ });
205
+ }
206
+ }
207
+
208
+ // Sort by mask bits descending for longest-prefix match
209
+ entries.sort((a, b) => b.maskBits - a.maskBits);
210
+ this.bootstrapEntries = entries;
211
+ } finally {
212
+ clearTimeout(timeoutId);
213
+ }
214
+ } catch (err: any) {
215
+ this.logger.debug?.(`Failed to load RDAP bootstrap: ${err.message}`);
216
+ this.bootstrapEntries = []; // empty = all RDAP lookups will skip
217
+ }
218
+ })();
219
+
220
+ await this.bootstrapPromise;
221
+ this.bootstrapPromise = null;
222
+ }
223
+
224
+ /**
225
+ * Find the RDAP base URL for a given IP via longest-prefix match
226
+ */
227
+ private matchRir(ip: string): string | null {
228
+ if (!this.bootstrapEntries || this.bootstrapEntries.length === 0) return null;
229
+
230
+ const ipNum = this.ipToNumber(ip);
231
+
232
+ for (const entry of this.bootstrapEntries) {
233
+ const mask = (0xFFFFFFFF << (32 - entry.maskBits)) >>> 0;
234
+ if ((ipNum & mask) === (entry.prefixNum & mask)) {
235
+ return entry.baseUrl;
236
+ }
237
+ }
238
+
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Query RDAP for registration data
244
+ */
245
+ private async queryRdap(ip: string): Promise<{
246
+ registrantOrg: string | null;
247
+ registrantCountry: string | null;
248
+ networkRange: string | null;
249
+ abuseContact: string | null;
250
+ } | null> {
251
+ await this.ensureBootstrap();
252
+ const baseUrl = this.matchRir(ip);
253
+ if (!baseUrl) return null;
254
+
255
+ const controller = new AbortController();
256
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
257
+ try {
258
+ const response = await fetch(`${baseUrl}/ip/${ip}`, {
259
+ signal: controller.signal,
260
+ headers: {
261
+ 'Accept': 'application/rdap+json',
262
+ 'User-Agent': '@push.rocks/smartnetwork',
263
+ },
264
+ });
265
+ if (!response.ok) return null;
266
+ const data = await response.json() as any;
267
+
268
+ let registrantOrg: string | null = null;
269
+ let registrantCountry: string | null = data.country || null;
270
+ let abuseContact: string | null = null;
271
+
272
+ // Parse network range
273
+ let networkRange: string | null = null;
274
+ if (data.cidr0_cidrs && data.cidr0_cidrs.length > 0) {
275
+ const cidr = data.cidr0_cidrs[0];
276
+ networkRange = `${cidr.v4prefix || cidr.v6prefix}/${cidr.length}`;
277
+ } else if (data.startAddress && data.endAddress) {
278
+ networkRange = `${data.startAddress} - ${data.endAddress}`;
279
+ }
280
+
281
+ // Parse entities
282
+ if (data.entities && Array.isArray(data.entities)) {
283
+ for (const entity of data.entities) {
284
+ const roles: string[] = entity.roles || [];
285
+
286
+ if (roles.includes('registrant') || roles.includes('administrative')) {
287
+ const orgName = this.extractVcardFn(entity);
288
+ if (orgName) registrantOrg = orgName;
289
+
290
+ // Try to get country from registrant address if not at top level
291
+ if (!registrantCountry) {
292
+ registrantCountry = this.extractVcardCountry(entity);
293
+ }
294
+ }
295
+
296
+ if (roles.includes('abuse')) {
297
+ abuseContact = this.extractVcardEmail(entity);
298
+ // Check nested entities for abuse contact
299
+ if (!abuseContact && entity.entities) {
300
+ for (const subEntity of entity.entities) {
301
+ const subRoles: string[] = subEntity.roles || [];
302
+ if (subRoles.includes('abuse')) {
303
+ abuseContact = this.extractVcardEmail(subEntity);
304
+ if (abuseContact) break;
305
+ }
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ return { registrantOrg, registrantCountry, networkRange, abuseContact };
313
+ } catch (err: any) {
314
+ this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
315
+ return null;
316
+ } finally {
317
+ clearTimeout(timeoutId);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Extract the 'fn' (formatted name) from an entity's vcardArray
323
+ */
324
+ private extractVcardFn(entity: any): string | null {
325
+ if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
326
+ const properties = entity.vcardArray[1];
327
+ if (!Array.isArray(properties)) return null;
328
+
329
+ for (const prop of properties) {
330
+ if (Array.isArray(prop) && prop[0] === 'fn') {
331
+ return prop[3] || null;
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+
337
+ /**
338
+ * Extract email from an entity's vcardArray
339
+ */
340
+ private extractVcardEmail(entity: any): string | null {
341
+ if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
342
+ const properties = entity.vcardArray[1];
343
+ if (!Array.isArray(properties)) return null;
344
+
345
+ for (const prop of properties) {
346
+ if (Array.isArray(prop) && prop[0] === 'email') {
347
+ return prop[3] || null;
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /**
354
+ * Extract country from an entity's vcardArray address field
355
+ */
356
+ private extractVcardCountry(entity: any): string | null {
357
+ if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
358
+ const properties = entity.vcardArray[1];
359
+ if (!Array.isArray(properties)) return null;
360
+
361
+ for (const prop of properties) {
362
+ if (Array.isArray(prop) && prop[0] === 'adr') {
363
+ // The label parameter often contains the full address with country at the end
364
+ const label = prop[1]?.label;
365
+ if (typeof label === 'string') {
366
+ const lines = label.split('\n');
367
+ const lastLine = lines[lines.length - 1]?.trim();
368
+ if (lastLine && lastLine.length > 1) return lastLine;
369
+ }
370
+ // Also check the structured value (7-element array, last element is country)
371
+ const value = prop[3];
372
+ if (Array.isArray(value) && value.length >= 7 && value[6]) {
373
+ return value[6];
374
+ }
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+
380
+ // ─── Team Cymru DNS Subsystem ───────────────────────────────────────
381
+
382
+ /**
383
+ * Query Team Cymru DNS for ASN information.
384
+ * Query format: reversed.ip.origin.asn.cymru.com TXT
385
+ * Response: "ASN | prefix | CC | rir | date"
386
+ */
387
+ private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
388
+ let dnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null = null;
389
+ try {
390
+ const reversed = ip.split('.').reverse().join('.');
391
+ const queryName = `${reversed}.origin.asn.cymru.com`;
392
+
393
+ dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
394
+ strategy: 'prefer-system',
395
+ allowDohFallback: true,
396
+ timeoutMs: this.timeout,
397
+ });
398
+
399
+ const records = await dnsClient.getRecordsTxt(queryName);
400
+ if (!records || records.length === 0) return null;
401
+
402
+ // Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
403
+ const txt = records[0].value || (records[0] as any).data;
404
+ if (!txt) return null;
405
+
406
+ const parts = txt.split('|').map((s: string) => s.trim());
407
+ if (parts.length < 3) return null;
408
+
409
+ const asn = parseInt(parts[0], 10);
410
+ if (isNaN(asn)) return null;
411
+
412
+ return {
413
+ asn,
414
+ prefix: parts[1] || '',
415
+ country: parts[2] || '',
416
+ };
417
+ } catch (err: any) {
418
+ this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
419
+ return null;
420
+ } finally {
421
+ if (dnsClient) {
422
+ dnsClient.destroy();
423
+ }
424
+ }
425
+ }
426
+
427
+ // ─── MaxMind GeoLite2 Subsystem ─────────────────────────────────────
428
+
429
+ /**
430
+ * Ensure MMDB readers are initialized. Downloads on first call,
431
+ * triggers background refresh if stale.
432
+ */
433
+ private async ensureReaders(): Promise<void> {
434
+ if (this.cityReader && this.asnReader) {
435
+ // Check if refresh needed
436
+ if (Date.now() - this.lastFetchTime > this.dbMaxAge && !this.refreshPromise) {
437
+ this.refreshPromise = this.downloadAndInitReaders()
438
+ .catch((err) => this.logger.debug?.(`Background MMDB refresh failed: ${err.message}`))
439
+ .finally(() => { this.refreshPromise = null; });
440
+ }
441
+ return;
442
+ }
443
+
444
+ // First time: blocking download
445
+ if (this.refreshPromise) {
446
+ await this.refreshPromise;
447
+ return;
448
+ }
449
+
450
+ this.refreshPromise = this.downloadAndInitReaders();
451
+ await this.refreshPromise;
452
+ this.refreshPromise = null;
453
+ }
454
+
455
+ /**
456
+ * Download MMDB files from CDN and create Reader instances
457
+ */
458
+ private async downloadAndInitReaders(): Promise<void> {
459
+ const [cityBuffer, asnBuffer] = await Promise.all([
460
+ this.fetchBuffer(CITY_MMDB_URL),
461
+ this.fetchBuffer(ASN_MMDB_URL),
462
+ ]);
463
+
464
+ this.cityReader = new plugins.maxmind.Reader<IIpLocationDbCityRecord & Response>(cityBuffer);
465
+ this.asnReader = new plugins.maxmind.Reader<AsnResponse>(asnBuffer);
466
+ this.lastFetchTime = Date.now();
467
+ this.logger.info?.('MaxMind MMDB databases loaded into memory');
468
+ }
469
+
470
+ /**
471
+ * Fetch a URL and return the response as a Buffer
472
+ */
473
+ private async fetchBuffer(url: string): Promise<Buffer> {
474
+ const response = await fetch(url, {
475
+ headers: { 'User-Agent': '@push.rocks/smartnetwork' },
476
+ });
477
+ if (!response.ok) {
478
+ throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
479
+ }
480
+ const arrayBuffer = await response.arrayBuffer();
481
+ return Buffer.from(arrayBuffer);
482
+ }
483
+
484
+ /**
485
+ * Query MaxMind for geo + ASN data
486
+ */
487
+ private async queryMaxMind(ip: string): Promise<{
488
+ country: string | null;
489
+ countryCode: string | null;
490
+ city: string | null;
491
+ latitude: number | null;
492
+ longitude: number | null;
493
+ accuracyRadius: number | null;
494
+ timezone: string | null;
495
+ asn: number | null;
496
+ asnOrg: string | null;
497
+ } | null> {
498
+ try {
499
+ await this.ensureReaders();
500
+ } catch (err: any) {
501
+ this.logger.debug?.(`Failed to initialize MaxMind readers: ${err.message}`);
502
+ return null;
503
+ }
504
+
505
+ if (!this.cityReader || !this.asnReader) return null;
506
+
507
+ let country: string | null = null;
508
+ let countryCode: string | null = null;
509
+ let city: string | null = null;
510
+ let latitude: number | null = null;
511
+ let longitude: number | null = null;
512
+ let accuracyRadius: number | null = null;
513
+ let timezone: string | null = null;
514
+ let asn: number | null = null;
515
+ let asnOrg: string | null = null;
516
+
517
+ // City lookup — @ip-location-db uses flat schema: city, country_code, latitude, longitude, etc.
518
+ try {
519
+ const cityResult = this.cityReader.get(ip);
520
+ if (cityResult) {
521
+ countryCode = cityResult.country_code || null;
522
+ city = cityResult.city || null;
523
+ latitude = cityResult.latitude ?? null;
524
+ longitude = cityResult.longitude ?? null;
525
+ timezone = cityResult.timezone || null;
526
+ // @ip-location-db does not include country name or accuracy_radius
527
+ // We leave country null (countryCode is available)
528
+ }
529
+ } catch (err: any) {
530
+ this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`);
531
+ }
532
+
533
+ // ASN lookup
534
+ try {
535
+ const asnResult = this.asnReader.get(ip);
536
+ if (asnResult) {
537
+ asn = asnResult.autonomous_system_number ?? null;
538
+ asnOrg = asnResult.autonomous_system_organization || null;
539
+ }
540
+ } catch (err: any) {
541
+ this.logger.debug?.(`MaxMind ASN lookup failed for ${ip}: ${err.message}`);
542
+ }
543
+
544
+ return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg };
545
+ }
546
+
547
+ // ─── Utilities ──────────────────────────────────────────────────────
548
+
549
+ /**
550
+ * Convert an IPv4 address string to a 32-bit unsigned number
551
+ */
552
+ private ipToNumber(ip: string): number {
553
+ const parts = ip.split('.');
554
+ return (
555
+ ((parseInt(parts[0], 10) << 24) |
556
+ (parseInt(parts[1], 10) << 16) |
557
+ (parseInt(parts[2], 10) << 8) |
558
+ parseInt(parts[3], 10)) >>> 0
559
+ );
560
+ }
561
+ }
@@ -58,7 +58,7 @@ export class PublicIp {
58
58
  this.logger.info?.(`Got IPv4 from ${service.name}: ${ip}`);
59
59
  return ip;
60
60
  }
61
- } catch (error) {
61
+ } catch (error: any) {
62
62
  this.logger.debug?.(`Failed to get IPv4 from ${service.name}: ${error.message}`);
63
63
  }
64
64
  }
@@ -80,7 +80,7 @@ export class PublicIp {
80
80
  this.logger.info?.(`Got IPv6 from ${service.name}: ${ip}`);
81
81
  return ip;
82
82
  }
83
- } catch (error) {
83
+ } catch (error: any) {
84
84
  this.logger.debug?.(`Failed to get IPv6 from ${service.name}: ${error.message}`);
85
85
  }
86
86
  }