@push.rocks/smartnetwork 4.9.0 → 4.10.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.
@@ -64,30 +64,12 @@ export interface IIpIntelligenceOptions {
64
64
  const CITY_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb';
65
65
  const ASN_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-asn-mmdb/geolite2-asn-ipv4.mmdb';
66
66
 
67
- // IANA bootstrap for RDAP
68
- const IANA_BOOTSTRAP_IPV4_URL = 'https://data.iana.org/rdap/ipv4.json';
69
-
70
67
  const DEFAULT_DB_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
71
68
  const DEFAULT_TIMEOUT = 5000;
72
69
 
73
- /**
74
- * Parsed IANA bootstrap entry: a CIDR prefix mapped to an RDAP base URL
75
- */
76
- interface IBootstrapEntry {
77
- prefix: string;
78
- prefixNum: number; // numeric representation of the network address
79
- maskBits: number;
80
- baseUrl: string;
81
- }
82
-
83
- interface IRdapNetworkInfo {
84
- networkRange: string | null;
85
- networkCidrs: string[] | null;
86
- }
87
-
88
70
  /**
89
71
  * IpIntelligence provides IP address intelligence by combining three data sources:
90
- * - RDAP (direct to RIRs) for registration/org data
72
+ * - RDAP (direct to RIRs, via @push.rocks/smartwhois) for registration/org data
91
73
  * - Team Cymru DNS for ASN
92
74
  * - MaxMind GeoLite2 (in-memory MMDB) for geolocation
93
75
  */
@@ -102,9 +84,8 @@ export class IpIntelligence {
102
84
  private lastFetchTime = 0;
103
85
  private refreshPromise: Promise<void> | null = null;
104
86
 
105
- // RDAP bootstrap cache
106
- private bootstrapEntries: IBootstrapEntry[] | null = null;
107
- private bootstrapPromise: Promise<void> | null = null;
87
+ // RDAP client from smartwhois (owns bootstrap caching and RIR matching)
88
+ private readonly rdapClient: plugins.smartwhois.RdapClient;
108
89
 
109
90
  // Optional injected smartdns client (shared by SmartNetwork)
110
91
  private readonly sharedDnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null;
@@ -113,6 +94,11 @@ export class IpIntelligence {
113
94
  this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
114
95
  this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
115
96
  this.sharedDnsClient = options?.dnsClient ?? null;
97
+ this.rdapClient = new plugins.smartwhois.RdapClient({
98
+ requestTimeoutMs: this.timeout,
99
+ totalTimeoutMs: this.timeout,
100
+ userAgent: '@push.rocks/smartnetwork',
101
+ });
116
102
  }
117
103
 
118
104
  /**
@@ -186,80 +172,10 @@ export class IpIntelligence {
186
172
  return result;
187
173
  }
188
174
 
189
- // ─── RDAP Subsystem ─────────────────────────────────────────────────
190
-
191
- /**
192
- * Load and cache the IANA RDAP bootstrap file
193
- */
194
- private async ensureBootstrap(): Promise<void> {
195
- if (this.bootstrapEntries) return;
196
- if (this.bootstrapPromise) {
197
- await this.bootstrapPromise;
198
- return;
199
- }
200
-
201
- this.bootstrapPromise = (async () => {
202
- try {
203
- const controller = new AbortController();
204
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
205
- try {
206
- const response = await fetch(IANA_BOOTSTRAP_IPV4_URL, {
207
- signal: controller.signal,
208
- headers: { 'User-Agent': '@push.rocks/smartnetwork' },
209
- });
210
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
211
- const data = await response.json() as { services: [string[], string[]][] };
212
-
213
- const entries: IBootstrapEntry[] = [];
214
- for (const [prefixes, urls] of data.services) {
215
- const baseUrl = urls[0]; // first URL is preferred
216
- for (const prefix of prefixes) {
217
- const [network, bits] = prefix.split('/');
218
- entries.push({
219
- prefix,
220
- prefixNum: this.ipToNumber(network),
221
- maskBits: parseInt(bits, 10),
222
- baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
223
- });
224
- }
225
- }
226
-
227
- // Sort by mask bits descending for longest-prefix match
228
- entries.sort((a, b) => b.maskBits - a.maskBits);
229
- this.bootstrapEntries = entries;
230
- } finally {
231
- clearTimeout(timeoutId);
232
- }
233
- } catch (err: any) {
234
- this.logger.debug?.(`Failed to load RDAP bootstrap: ${err.message}`);
235
- this.bootstrapEntries = []; // empty = all RDAP lookups will skip
236
- }
237
- })();
238
-
239
- await this.bootstrapPromise;
240
- this.bootstrapPromise = null;
241
- }
175
+ // ─── RDAP Subsystem (delegated to @push.rocks/smartwhois) ───────────
242
176
 
243
177
  /**
244
- * Find the RDAP base URL for a given IP via longest-prefix match
245
- */
246
- private matchRir(ip: string): string | null {
247
- if (!this.bootstrapEntries || this.bootstrapEntries.length === 0) return null;
248
-
249
- const ipNum = this.ipToNumber(ip);
250
-
251
- for (const entry of this.bootstrapEntries) {
252
- const mask = (0xFFFFFFFF << (32 - entry.maskBits)) >>> 0;
253
- if ((ipNum & mask) === (entry.prefixNum & mask)) {
254
- return entry.baseUrl;
255
- }
256
- }
257
-
258
- return null;
259
- }
260
-
261
- /**
262
- * Query RDAP for registration data
178
+ * Query RDAP for registration data via the smartwhois RdapClient (IPv4 and IPv6).
263
179
  */
264
180
  private async queryRdap(ip: string): Promise<{
265
181
  registrantOrg: string | null;
@@ -268,160 +184,20 @@ export class IpIntelligence {
268
184
  networkCidrs: string[] | null;
269
185
  abuseContact: string | null;
270
186
  } | null> {
271
- await this.ensureBootstrap();
272
- const baseUrl = this.matchRir(ip);
273
- if (!baseUrl) return null;
274
-
275
- const controller = new AbortController();
276
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
277
187
  try {
278
- const response = await fetch(`${baseUrl}/ip/${ip}`, {
279
- signal: controller.signal,
280
- headers: {
281
- 'Accept': 'application/rdap+json',
282
- 'User-Agent': '@push.rocks/smartnetwork',
283
- },
284
- });
285
- if (!response.ok) return null;
286
- const data = await response.json() as any;
287
-
288
- let registrantOrg: string | null = null;
289
- let registrantCountry: string | null = data.country || null;
290
- let abuseContact: string | null = null;
291
-
292
- const { networkRange, networkCidrs } = this.parseRdapNetworkInfo(data);
293
-
294
- // Parse entities
295
- if (data.entities && Array.isArray(data.entities)) {
296
- for (const entity of data.entities) {
297
- const roles: string[] = entity.roles || [];
298
-
299
- if (roles.includes('registrant') || roles.includes('administrative')) {
300
- const orgName = this.extractVcardFn(entity);
301
- if (orgName) registrantOrg = orgName;
302
-
303
- // Try to get country from registrant address if not at top level
304
- if (!registrantCountry) {
305
- registrantCountry = this.extractVcardCountry(entity);
306
- }
307
- }
308
-
309
- if (roles.includes('abuse')) {
310
- abuseContact = this.extractVcardEmail(entity);
311
- // Check nested entities for abuse contact
312
- if (!abuseContact && entity.entities) {
313
- for (const subEntity of entity.entities) {
314
- const subRoles: string[] = subEntity.roles || [];
315
- if (subRoles.includes('abuse')) {
316
- abuseContact = this.extractVcardEmail(subEntity);
317
- if (abuseContact) break;
318
- }
319
- }
320
- }
321
- }
322
- }
323
- }
324
-
325
- return { registrantOrg, registrantCountry, networkRange, networkCidrs, abuseContact };
188
+ const rdapRecord = await this.rdapClient.lookupIp(ip);
189
+ if (!rdapRecord) return null;
190
+ return {
191
+ registrantOrg: rdapRecord.registrantOrg ?? null,
192
+ registrantCountry: rdapRecord.registrantCountry ?? null,
193
+ networkRange: rdapRecord.networkRange ?? null,
194
+ networkCidrs: rdapRecord.networkCidrs ?? null,
195
+ abuseContact: rdapRecord.abuseEmail ?? null,
196
+ };
326
197
  } catch (err: any) {
327
198
  this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
328
199
  return null;
329
- } finally {
330
- clearTimeout(timeoutId);
331
- }
332
- }
333
-
334
- private parseRdapNetworkInfo(data: any): IRdapNetworkInfo {
335
- const cidrs = this.extractRdapCidrs(data);
336
- if (cidrs.length > 0) {
337
- return {
338
- networkRange: cidrs[0],
339
- networkCidrs: cidrs,
340
- };
341
200
  }
342
-
343
- if (typeof data.startAddress === 'string' && typeof data.endAddress === 'string') {
344
- const rangeCidrs = this.ipv4RangeToCidrs(data.startAddress, data.endAddress);
345
- return {
346
- networkRange: rangeCidrs.length === 1
347
- ? rangeCidrs[0]
348
- : `${data.startAddress} - ${data.endAddress}`,
349
- networkCidrs: rangeCidrs.length > 0 ? rangeCidrs : null,
350
- };
351
- }
352
-
353
- return { networkRange: null, networkCidrs: null };
354
- }
355
-
356
- private extractRdapCidrs(data: any): string[] {
357
- if (!Array.isArray(data.cidr0_cidrs)) return [];
358
- return data.cidr0_cidrs
359
- .map((cidr: any) => {
360
- const prefix = cidr?.v4prefix || cidr?.v6prefix;
361
- const length = Number(cidr?.length);
362
- if (typeof prefix !== 'string' || !Number.isInteger(length)) return null;
363
- return `${prefix}/${length}`;
364
- })
365
- .filter(Boolean) as string[];
366
- }
367
-
368
- /**
369
- * Extract the 'fn' (formatted name) from an entity's vcardArray
370
- */
371
- private extractVcardFn(entity: any): string | null {
372
- if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
373
- const properties = entity.vcardArray[1];
374
- if (!Array.isArray(properties)) return null;
375
-
376
- for (const prop of properties) {
377
- if (Array.isArray(prop) && prop[0] === 'fn') {
378
- return prop[3] || null;
379
- }
380
- }
381
- return null;
382
- }
383
-
384
- /**
385
- * Extract email from an entity's vcardArray
386
- */
387
- private extractVcardEmail(entity: any): string | null {
388
- if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
389
- const properties = entity.vcardArray[1];
390
- if (!Array.isArray(properties)) return null;
391
-
392
- for (const prop of properties) {
393
- if (Array.isArray(prop) && prop[0] === 'email') {
394
- return prop[3] || null;
395
- }
396
- }
397
- return null;
398
- }
399
-
400
- /**
401
- * Extract country from an entity's vcardArray address field
402
- */
403
- private extractVcardCountry(entity: any): string | null {
404
- if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
405
- const properties = entity.vcardArray[1];
406
- if (!Array.isArray(properties)) return null;
407
-
408
- for (const prop of properties) {
409
- if (Array.isArray(prop) && prop[0] === 'adr') {
410
- // The label parameter often contains the full address with country at the end
411
- const label = prop[1]?.label;
412
- if (typeof label === 'string') {
413
- const lines = label.split('\n');
414
- const lastLine = lines[lines.length - 1]?.trim();
415
- if (lastLine && lastLine.length > 1) return lastLine;
416
- }
417
- // Also check the structured value (7-element array, last element is country)
418
- const value = prop[3];
419
- if (Array.isArray(value) && value.length >= 7 && value[6]) {
420
- return value[6];
421
- }
422
- }
423
- }
424
- return null;
425
201
  }
426
202
 
427
203
  // ─── Team Cymru DNS Subsystem ───────────────────────────────────────
@@ -601,71 +377,4 @@ export class IpIntelligence {
601
377
 
602
378
  return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg };
603
379
  }
604
-
605
- // ─── Utilities ──────────────────────────────────────────────────────
606
-
607
- /**
608
- * Convert an IPv4 address string to a 32-bit unsigned number
609
- */
610
- private ipToNumber(ip: string): number {
611
- const parts = ip.split('.');
612
- return (
613
- ((parseInt(parts[0], 10) << 24) |
614
- (parseInt(parts[1], 10) << 16) |
615
- (parseInt(parts[2], 10) << 8) |
616
- parseInt(parts[3], 10)) >>> 0
617
- );
618
- }
619
-
620
- private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
621
- const start = this.ipv4ToBigInt(startIp);
622
- const end = this.ipv4ToBigInt(endIp);
623
- if (start === undefined || end === undefined || start > end) return [];
624
-
625
- const cidrs: string[] = [];
626
- let current = start;
627
- while (current <= end) {
628
- let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
629
- const remaining = end - current + 1n;
630
- while (maxBlockSize > remaining) {
631
- maxBlockSize = maxBlockSize / 2n;
632
- }
633
- const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
634
- cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
635
- current += maxBlockSize;
636
- }
637
- return cidrs;
638
- }
639
-
640
- private ipv4ToBigInt(ip: string): bigint | undefined {
641
- const parts = ip.trim().split('.');
642
- if (parts.length !== 4) return undefined;
643
- let result = 0n;
644
- for (const part of parts) {
645
- if (!/^\d+$/.test(part)) return undefined;
646
- const number = Number(part);
647
- if (!Number.isInteger(number) || number < 0 || number > 255) return undefined;
648
- result = (result * 256n) + BigInt(number);
649
- }
650
- return result;
651
- }
652
-
653
- private numberToIpv4(value: bigint): string {
654
- return [
655
- Number((value >> 24n) & 255n),
656
- Number((value >> 16n) & 255n),
657
- Number((value >> 8n) & 255n),
658
- Number(value & 255n),
659
- ].join('.');
660
- }
661
-
662
- private powerOfTwoExponent(value: bigint): number {
663
- let exponent = 0;
664
- let remaining = value;
665
- while (remaining > 1n) {
666
- remaining >>= 1n;
667
- exponent++;
668
- }
669
- return exponent;
670
- }
671
380
  }
@@ -8,8 +8,9 @@ export { os, https, perfHooks };
8
8
  // @pushrocks scope
9
9
  import * as smartdns from '@push.rocks/smartdns';
10
10
  import * as smartrust from '@push.rocks/smartrust';
11
+ import * as smartwhois from '@push.rocks/smartwhois';
11
12
 
12
- export { smartdns, smartrust };
13
+ export { smartdns, smartrust, smartwhois };
13
14
 
14
15
  // third party
15
16
  import * as maxmind from 'maxmind';