@push.rocks/smartnetwork 4.3.0 → 4.5.0

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