@push.rocks/smartnetwork 4.9.0 → 4.10.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.
- package/dist_rust/rustnetwork_linux_amd64 +0 -0
- package/dist_rust/rustnetwork_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/smartnetwork.classes.domainintelligence.d.ts +23 -62
- package/dist_ts/smartnetwork.classes.domainintelligence.js +113 -357
- package/dist_ts/smartnetwork.classes.ipintelligence.d.ts +11 -36
- package/dist_ts/smartnetwork.classes.ipintelligence.js +83 -316
- package/dist_ts/smartnetwork.classes.rustbridge.d.ts +12 -8
- package/dist_ts/smartnetwork.classes.rustbridge.js +70 -15
- package/dist_ts/smartnetwork.classes.smartnetwork.d.ts +10 -3
- package/dist_ts/smartnetwork.classes.smartnetwork.js +132 -50
- package/dist_ts/smartnetwork.plugins.d.ts +2 -1
- package/dist_ts/smartnetwork.plugins.js +3 -2
- package/package.json +8 -7
- package/readme.hints.md +3 -3
- package/readme.md +23 -9
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/smartnetwork.classes.domainintelligence.ts +121 -381
- package/ts/smartnetwork.classes.ipintelligence.ts +88 -345
- package/ts/smartnetwork.classes.rustbridge.ts +72 -14
- package/ts/smartnetwork.classes.smartnetwork.ts +140 -52
- package/ts/smartnetwork.plugins.ts +2 -1
|
@@ -53,9 +53,9 @@ export interface IIpIntelligenceOptions {
|
|
|
53
53
|
timeout?: number;
|
|
54
54
|
/**
|
|
55
55
|
* Optional injected smartdns client. When provided, IpIntelligence will
|
|
56
|
-
* not create or
|
|
56
|
+
* not create or terminate its own client (the owner — typically SmartNetwork —
|
|
57
57
|
* manages lifecycle). When omitted, a short-lived client is created per
|
|
58
|
-
* Team Cymru lookup and
|
|
58
|
+
* Team Cymru lookup and terminated in finally.
|
|
59
59
|
*/
|
|
60
60
|
dnsClient?: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>;
|
|
61
61
|
}
|
|
@@ -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,17 +84,23 @@ export class IpIntelligence {
|
|
|
102
84
|
private lastFetchTime = 0;
|
|
103
85
|
private refreshPromise: Promise<void> | null = null;
|
|
104
86
|
|
|
105
|
-
// RDAP bootstrap
|
|
106
|
-
private
|
|
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;
|
|
92
|
+
private ownedDnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null = null;
|
|
93
|
+
private dnsLifecycleQueue: Promise<void> = Promise.resolve();
|
|
111
94
|
|
|
112
95
|
constructor(options?: IIpIntelligenceOptions) {
|
|
113
96
|
this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
|
|
114
97
|
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
115
98
|
this.sharedDnsClient = options?.dnsClient ?? null;
|
|
99
|
+
this.rdapClient = new plugins.smartwhois.RdapClient({
|
|
100
|
+
requestTimeoutMs: this.timeout,
|
|
101
|
+
totalTimeoutMs: this.timeout,
|
|
102
|
+
userAgent: '@push.rocks/smartnetwork',
|
|
103
|
+
});
|
|
116
104
|
}
|
|
117
105
|
|
|
118
106
|
/**
|
|
@@ -144,6 +132,10 @@ export class IpIntelligence {
|
|
|
144
132
|
this.queryMaxMind(ip),
|
|
145
133
|
]);
|
|
146
134
|
|
|
135
|
+
if (cymruResult.status === 'rejected') {
|
|
136
|
+
throw cymruResult.reason;
|
|
137
|
+
}
|
|
138
|
+
|
|
147
139
|
// Merge RDAP results
|
|
148
140
|
if (rdapResult.status === 'fulfilled' && rdapResult.value) {
|
|
149
141
|
const rdap = rdapResult.value;
|
|
@@ -186,80 +178,16 @@ export class IpIntelligence {
|
|
|
186
178
|
return result;
|
|
187
179
|
}
|
|
188
180
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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;
|
|
181
|
+
/** Retry and confirm cleanup of a standalone DNS client, if one is retained. */
|
|
182
|
+
public stop(): Promise<void> {
|
|
183
|
+
if (this.sharedDnsClient) return Promise.resolve();
|
|
184
|
+
return this.enqueueDnsLifecycle(() => this.terminateOwnedDnsClient());
|
|
241
185
|
}
|
|
242
186
|
|
|
243
|
-
|
|
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
|
-
}
|
|
187
|
+
// ─── RDAP Subsystem (delegated to @push.rocks/smartwhois) ───────────
|
|
260
188
|
|
|
261
189
|
/**
|
|
262
|
-
* Query RDAP for registration data
|
|
190
|
+
* Query RDAP for registration data via the smartwhois RdapClient (IPv4 and IPv6).
|
|
263
191
|
*/
|
|
264
192
|
private async queryRdap(ip: string): Promise<{
|
|
265
193
|
registrantOrg: string | null;
|
|
@@ -268,160 +196,20 @@ export class IpIntelligence {
|
|
|
268
196
|
networkCidrs: string[] | null;
|
|
269
197
|
abuseContact: string | null;
|
|
270
198
|
} | 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
199
|
try {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 };
|
|
200
|
+
const rdapRecord = await this.rdapClient.lookupIp(ip);
|
|
201
|
+
if (!rdapRecord) return null;
|
|
202
|
+
return {
|
|
203
|
+
registrantOrg: rdapRecord.registrantOrg ?? null,
|
|
204
|
+
registrantCountry: rdapRecord.registrantCountry ?? null,
|
|
205
|
+
networkRange: rdapRecord.networkRange ?? null,
|
|
206
|
+
networkCidrs: rdapRecord.networkCidrs ?? null,
|
|
207
|
+
abuseContact: rdapRecord.abuseEmail ?? null,
|
|
208
|
+
};
|
|
326
209
|
} catch (err: any) {
|
|
327
210
|
this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
|
|
328
211
|
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
|
-
}
|
|
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
212
|
}
|
|
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
213
|
}
|
|
426
214
|
|
|
427
215
|
// ─── Team Cymru DNS Subsystem ───────────────────────────────────────
|
|
@@ -432,46 +220,68 @@ export class IpIntelligence {
|
|
|
432
220
|
* Response: "ASN | prefix | CC | rir | date"
|
|
433
221
|
*/
|
|
434
222
|
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
223
|
+
const query = async (
|
|
224
|
+
dnsClientArg: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>,
|
|
225
|
+
) => {
|
|
226
|
+
try {
|
|
227
|
+
const reversed = ip.split('.').reverse().join('.');
|
|
228
|
+
const queryName = `${reversed}.origin.asn.cymru.com`;
|
|
229
|
+
const records = await dnsClientArg.getRecordsTxt(queryName);
|
|
230
|
+
if (!records || records.length === 0) return null;
|
|
231
|
+
|
|
232
|
+
// Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
|
|
233
|
+
const txt = records[0].value || (records[0] as any).data;
|
|
234
|
+
if (!txt) return null;
|
|
235
|
+
|
|
236
|
+
const parts = txt.split('|').map((s: string) => s.trim());
|
|
237
|
+
if (parts.length < 3) return null;
|
|
238
|
+
|
|
239
|
+
const asn = parseInt(parts[0], 10);
|
|
240
|
+
if (isNaN(asn)) return null;
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
asn,
|
|
244
|
+
prefix: parts[1] || '',
|
|
245
|
+
country: parts[2] || '',
|
|
246
|
+
};
|
|
247
|
+
} catch (err: any) {
|
|
248
|
+
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
|
|
249
|
+
return null;
|
|
447
250
|
}
|
|
251
|
+
};
|
|
448
252
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
|
|
453
|
-
const txt = records[0].value || (records[0] as any).data;
|
|
454
|
-
if (!txt) return null;
|
|
253
|
+
if (this.sharedDnsClient) {
|
|
254
|
+
return query(this.sharedDnsClient);
|
|
255
|
+
}
|
|
455
256
|
|
|
456
|
-
|
|
457
|
-
|
|
257
|
+
return this.enqueueDnsLifecycle(async () => {
|
|
258
|
+
await this.terminateOwnedDnsClient();
|
|
259
|
+
const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
|
260
|
+
strategy: 'prefer-system',
|
|
261
|
+
allowDohFallback: true,
|
|
262
|
+
timeoutMs: this.timeout,
|
|
263
|
+
});
|
|
264
|
+
this.ownedDnsClient = dnsClient;
|
|
265
|
+
try {
|
|
266
|
+
return await query(dnsClient);
|
|
267
|
+
} finally {
|
|
268
|
+
await this.terminateOwnedDnsClient();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
458
272
|
|
|
459
|
-
|
|
460
|
-
|
|
273
|
+
private enqueueDnsLifecycle<T>(operationArg: () => Promise<T>): Promise<T> {
|
|
274
|
+
const result = this.dnsLifecycleQueue.then(operationArg);
|
|
275
|
+
this.dnsLifecycleQueue = result.then(() => {}, () => {});
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
461
278
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
|
|
469
|
-
return null;
|
|
470
|
-
} finally {
|
|
471
|
-
// Only destroy clients we created ourselves; leave injected ones alone.
|
|
472
|
-
if (!external && dnsClient) {
|
|
473
|
-
dnsClient.destroy();
|
|
474
|
-
}
|
|
279
|
+
private async terminateOwnedDnsClient(): Promise<void> {
|
|
280
|
+
const dnsClient = this.ownedDnsClient;
|
|
281
|
+
if (!dnsClient) return;
|
|
282
|
+
await dnsClient.terminate();
|
|
283
|
+
if (this.ownedDnsClient === dnsClient) {
|
|
284
|
+
this.ownedDnsClient = null;
|
|
475
285
|
}
|
|
476
286
|
}
|
|
477
287
|
|
|
@@ -601,71 +411,4 @@ export class IpIntelligence {
|
|
|
601
411
|
|
|
602
412
|
return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg };
|
|
603
413
|
}
|
|
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
414
|
}
|
|
@@ -46,6 +46,17 @@ type TNetworkCommands = {
|
|
|
46
46
|
};
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/** Internal marker for an activation failure whose cleanup still needs an owner. */
|
|
50
|
+
export class RustNetworkBridgeActivationCleanupError extends AggregateError {
|
|
51
|
+
constructor(activationErrorArg: unknown, cleanupErrorArg: unknown) {
|
|
52
|
+
super(
|
|
53
|
+
[activationErrorArg, cleanupErrorArg],
|
|
54
|
+
'RustNetworkBridge activation failed and cleanup could not be confirmed',
|
|
55
|
+
);
|
|
56
|
+
this.name = 'RustNetworkBridgeActivationCleanupError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
function getPlatformSuffix(): string | null {
|
|
50
61
|
const platform = process.platform;
|
|
51
62
|
const arch = process.arch;
|
|
@@ -85,6 +96,9 @@ export class RustNetworkBridge {
|
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TNetworkCommands>>;
|
|
99
|
+
private readonly owners = new Set<object>();
|
|
100
|
+
private cleanupRequired = false;
|
|
101
|
+
private lifecycleQueue: Promise<void> = Promise.resolve();
|
|
88
102
|
|
|
89
103
|
private constructor() {
|
|
90
104
|
const packageDir = path.resolve(
|
|
@@ -115,28 +129,72 @@ export class RustNetworkBridge {
|
|
|
115
129
|
});
|
|
116
130
|
}
|
|
117
131
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const ok = await this.bridge.spawn();
|
|
123
|
-
if (!ok) {
|
|
124
|
-
throw new Error('Failed to spawn rustnetwork binary');
|
|
125
|
-
}
|
|
132
|
+
private enqueueLifecycle(operationArg: () => Promise<void>): Promise<void> {
|
|
133
|
+
const result = this.lifecycleQueue.then(operationArg);
|
|
134
|
+
this.lifecycleQueue = result.catch(() => {});
|
|
135
|
+
return result;
|
|
126
136
|
}
|
|
127
137
|
|
|
128
138
|
/**
|
|
129
|
-
*
|
|
139
|
+
* Acquire the shared Rust binary and wait for it to be ready.
|
|
130
140
|
*/
|
|
131
|
-
public
|
|
132
|
-
this.
|
|
141
|
+
public start(ownerArg: object = this): Promise<void> {
|
|
142
|
+
return this.enqueueLifecycle(async () => {
|
|
143
|
+
if (this.cleanupRequired) {
|
|
144
|
+
throw new Error('RustNetworkBridge cleanup must succeed before it can start again');
|
|
145
|
+
}
|
|
146
|
+
if (this.owners.size > 0 && !this.bridge.running) {
|
|
147
|
+
throw new Error('RustNetworkBridge stopped while it still had active owners');
|
|
148
|
+
}
|
|
149
|
+
if (this.owners.has(ownerArg)) return;
|
|
150
|
+
if (this.owners.size === 0) {
|
|
151
|
+
let activationError: unknown;
|
|
152
|
+
try {
|
|
153
|
+
const ok = await this.bridge.spawn();
|
|
154
|
+
if (ok) {
|
|
155
|
+
this.owners.add(ownerArg);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
activationError = new Error('Failed to spawn rustnetwork binary');
|
|
159
|
+
} catch (error) {
|
|
160
|
+
activationError = error;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await this.bridge.terminate();
|
|
165
|
+
} catch (cleanupError) {
|
|
166
|
+
this.owners.add(ownerArg);
|
|
167
|
+
this.cleanupRequired = true;
|
|
168
|
+
throw new RustNetworkBridgeActivationCleanupError(
|
|
169
|
+
activationError,
|
|
170
|
+
cleanupError,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
throw activationError;
|
|
174
|
+
}
|
|
175
|
+
this.owners.add(ownerArg);
|
|
176
|
+
});
|
|
133
177
|
}
|
|
134
178
|
|
|
135
179
|
/**
|
|
136
|
-
*
|
|
180
|
+
* Release one owner and terminate the Rust binary after the last release.
|
|
137
181
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
182
|
+
public stop(ownerArg: object = this): Promise<void> {
|
|
183
|
+
return this.enqueueLifecycle(async () => {
|
|
184
|
+
if (!this.owners.has(ownerArg)) return;
|
|
185
|
+
if (this.owners.size > 1) {
|
|
186
|
+
this.owners.delete(ownerArg);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await this.bridge.terminate();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.cleanupRequired = true;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
this.cleanupRequired = false;
|
|
196
|
+
this.owners.delete(ownerArg);
|
|
197
|
+
});
|
|
140
198
|
}
|
|
141
199
|
|
|
142
200
|
// ===== Command wrappers =====
|