@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
package/readme.md
CHANGED
|
@@ -16,7 +16,7 @@ pnpm add @push.rocks/smartnetwork
|
|
|
16
16
|
|
|
17
17
|
**@push.rocks/smartnetwork** is your Swiss Army knife for network diagnostics in Node.js. Whether you're building monitoring dashboards, investigating IP/domain ownership, or debugging connectivity — this library has you covered with a clean, async API and zero-config setup.
|
|
18
18
|
|
|
19
|
-
Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port checks, gateway detection) are powered by a bundled **Rust binary** for maximum performance and cross-platform reliability.
|
|
19
|
+
Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port checks, gateway detection) are powered by a bundled **Rust binary** for maximum performance and cross-platform reliability. The remaining orchestration runs in TypeScript, while DNS and intelligence lookups can invoke smartdns's bundled Rust client for UDP or DoH fallback.
|
|
20
20
|
|
|
21
21
|
### ✨ Key Features
|
|
22
22
|
|
|
@@ -47,7 +47,7 @@ await network.start();
|
|
|
47
47
|
|
|
48
48
|
// ... use the network instance ...
|
|
49
49
|
|
|
50
|
-
// Clean up when done
|
|
50
|
+
// Clean up when done. This confirms DNS cleanup and releases the shared Rust bridge.
|
|
51
51
|
await network.stop();
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -61,9 +61,9 @@ const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s TTL
|
|
|
61
61
|
|
|
62
62
|
### 🕵️ IP Intelligence
|
|
63
63
|
|
|
64
|
-
Get ASN, organization, geolocation, and RDAP registration data for any IPv4 address. Combines three free public data sources in parallel:
|
|
64
|
+
Get ASN, organization, geolocation, and RDAP registration data for any IPv4 address (RDAP registration data also works for IPv6). Combines three free public data sources in parallel:
|
|
65
65
|
|
|
66
|
-
- **RDAP** — direct queries to RIRs (RIPE, ARIN, APNIC, LACNIC, AFRINIC) for authoritative registration data
|
|
66
|
+
- **RDAP** — direct queries to RIRs (RIPE, ARIN, APNIC, LACNIC, AFRINIC) for authoritative registration data, via [`@push.rocks/smartwhois`](https://code.foss.global/push.rocks/smartwhois)
|
|
67
67
|
- **Team Cymru DNS** — fast ASN resolution via DNS TXT records
|
|
68
68
|
- **MaxMind GeoLite2** — in-memory MMDB databases (auto-downloaded from CDN, periodically refreshed)
|
|
69
69
|
|
|
@@ -133,7 +133,7 @@ interface IIpIntelligenceResult {
|
|
|
133
133
|
|
|
134
134
|
Get comprehensive domain registration and DNS data. Runs an **RDAP layer** and a **DNS layer** in parallel, then merges results:
|
|
135
135
|
|
|
136
|
-
- **RDAP** — queries the correct registry RDAP server (discovered via the [IANA DNS bootstrap](https://data.iana.org/rdap/dns.json)) for registrar, registrant, events, status flags, DNSSEC, and abuse contacts
|
|
136
|
+
- **RDAP** — queries the correct registry RDAP server (discovered via the [IANA DNS bootstrap](https://data.iana.org/rdap/dns.json)) for registrar, registrant, events, status flags, DNSSEC, and abuse contacts, via [`@push.rocks/smartwhois`](https://code.foss.global/push.rocks/smartwhois)
|
|
137
137
|
- **DNS** — queries A, AAAA, NS, MX, TXT, and SOA records via `@push.rocks/smartdns` (system resolver first, DoH fallback)
|
|
138
138
|
|
|
139
139
|
For gTLDs (`.com`, `.org`, `.net`, etc.) you get the full picture from both layers. For ccTLDs without RDAP support (`.de`, `.fr`, `.nl`, etc.), the DNS layer fills in nameservers, resolved IPs, MX, TXT, and SOA — so you still get useful intelligence even when RDAP isn't available.
|
|
@@ -225,6 +225,20 @@ interface IDomainIntelligenceResult {
|
|
|
225
225
|
|
|
226
226
|
> 💡 **RDAP vs DNS nameservers**: When RDAP is available (most gTLDs), `nameservers` comes from the registry's authoritative parent-zone delegation (`nameserversSource: 'rdap'`). When RDAP is unavailable (many ccTLDs), the DNS layer fills in nameservers via NS record resolution (`nameserversSource: 'dns'`). The `nameserversSource` field tells you which source won.
|
|
227
227
|
|
|
228
|
+
When using `IpIntelligence` or `DomainIntelligence` directly without injecting a smartdns client, await `stop()` after the final lookup. A failed confirmed shutdown rejects and retains the client, so call `stop()` again to retry cleanup:
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { DomainIntelligence } from '@push.rocks/smartnetwork';
|
|
232
|
+
|
|
233
|
+
const intelligence = new DomainIntelligence();
|
|
234
|
+
try {
|
|
235
|
+
const result = await intelligence.getIntelligence('example.com');
|
|
236
|
+
console.log(result.registrarName);
|
|
237
|
+
} finally {
|
|
238
|
+
await intelligence.stop();
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
228
242
|
---
|
|
229
243
|
|
|
230
244
|
### 🏎️ Speed Testing
|
|
@@ -474,7 +488,7 @@ const monitor = async () => {
|
|
|
474
488
|
|
|
475
489
|
| Method | Description | Requires Rust |
|
|
476
490
|
|--------|-------------|:---:|
|
|
477
|
-
| `start()` / `stop()` |
|
|
491
|
+
| `start()` / `stop()` | Acquire/release the shared Rust bridge and confirm smartdns cleanup. Retry `stop()` if cleanup rejects | Lifecycle |
|
|
478
492
|
| `getSpeed(opts?)` | Cloudflare speed test (download + upload) | No |
|
|
479
493
|
| `ping(host, opts?)` | ICMP ping with optional multi-ping stats | Yes |
|
|
480
494
|
| `traceroute(host, opts?)` | Hop-by-hop network path analysis | Yes |
|
|
@@ -484,10 +498,10 @@ const monitor = async () => {
|
|
|
484
498
|
| `getGateways()` | List all network interfaces | No |
|
|
485
499
|
| `getDefaultGateway()` | Get default gateway info | Yes |
|
|
486
500
|
| `getPublicIps()` | Discover public IPv4/IPv6 (4 fallback services) | No |
|
|
487
|
-
| `resolveDns(host)` | Resolve A, AAAA, MX records |
|
|
501
|
+
| `resolveDns(host)` | Resolve A, AAAA, MX records | SmartDNS fallback |
|
|
488
502
|
| `checkEndpoint(url, opts?)` | HTTP/HTTPS health check with RTT | No |
|
|
489
|
-
| `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration |
|
|
490
|
-
| `getDomainIntelligence(domain)` | Registrar, nameservers, events, DNS records, DNSSEC |
|
|
503
|
+
| `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration | SmartDNS fallback |
|
|
504
|
+
| `getDomainIntelligence(domain)` | Registrar, nameservers, events, DNS records, DNSSEC | SmartDNS fallback |
|
|
491
505
|
|
|
492
506
|
### Standalone helpers
|
|
493
507
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -63,15 +63,13 @@ export interface IDomainIntelligenceOptions {
|
|
|
63
63
|
timeout?: number;
|
|
64
64
|
/**
|
|
65
65
|
* Optional injected smartdns client. When provided, DomainIntelligence
|
|
66
|
-
* will not create or
|
|
66
|
+
* will not create or terminate its own client (the owner — typically
|
|
67
67
|
* SmartNetwork — manages lifecycle). When omitted, a short-lived client
|
|
68
|
-
* is created per DNS-layer query and
|
|
68
|
+
* is created per DNS-layer query and terminated in finally.
|
|
69
69
|
*/
|
|
70
70
|
dnsClient?: TSmartdnsClient;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// IANA bootstrap for domain RDAP
|
|
74
|
-
const IANA_BOOTSTRAP_DNS_URL = 'https://data.iana.org/rdap/dns.json';
|
|
75
73
|
const DEFAULT_TIMEOUT = 5000;
|
|
76
74
|
|
|
77
75
|
/**
|
|
@@ -103,23 +101,30 @@ function emptyResult(domain: string | null = null): IDomainIntelligenceResult {
|
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
/**
|
|
106
|
-
* DomainIntelligence performs RDAP lookups for domain names
|
|
107
|
-
*
|
|
104
|
+
* DomainIntelligence performs RDAP lookups for domain names via
|
|
105
|
+
* @push.rocks/smartwhois, which resolves the correct registry RDAP endpoint
|
|
106
|
+
* per TLD through the IANA DNS bootstrap.
|
|
108
107
|
*/
|
|
109
108
|
export class DomainIntelligence {
|
|
110
109
|
private readonly logger = getLogger();
|
|
111
110
|
private readonly timeout: number;
|
|
112
111
|
|
|
113
|
-
//
|
|
114
|
-
private
|
|
115
|
-
private bootstrapPromise: Promise<void> | null = null;
|
|
112
|
+
// RDAP client from smartwhois (owns bootstrap caching and registry failover)
|
|
113
|
+
private readonly rdapClient: plugins.smartwhois.RdapClient;
|
|
116
114
|
|
|
117
115
|
// Optional injected smartdns client (shared by SmartNetwork)
|
|
118
116
|
private readonly sharedDnsClient: TSmartdnsClient | null;
|
|
117
|
+
private ownedDnsClient: TSmartdnsClient | null = null;
|
|
118
|
+
private dnsLifecycleQueue: Promise<void> = Promise.resolve();
|
|
119
119
|
|
|
120
120
|
constructor(options?: IDomainIntelligenceOptions) {
|
|
121
121
|
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
122
122
|
this.sharedDnsClient = options?.dnsClient ?? null;
|
|
123
|
+
this.rdapClient = new plugins.smartwhois.RdapClient({
|
|
124
|
+
requestTimeoutMs: this.timeout,
|
|
125
|
+
totalTimeoutMs: this.timeout,
|
|
126
|
+
userAgent: '@push.rocks/smartnetwork',
|
|
127
|
+
});
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
/**
|
|
@@ -166,29 +171,62 @@ export class DomainIntelligence {
|
|
|
166
171
|
}
|
|
167
172
|
}
|
|
168
173
|
|
|
174
|
+
if (dnsSettled.status === 'rejected') {
|
|
175
|
+
throw dnsSettled.reason;
|
|
176
|
+
}
|
|
177
|
+
|
|
169
178
|
return result;
|
|
170
179
|
}
|
|
171
180
|
|
|
172
|
-
|
|
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());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── RDAP Layer (delegated to @push.rocks/smartwhois) ───────────────
|
|
173
188
|
|
|
174
189
|
/**
|
|
175
|
-
* Run the full RDAP lookup flow for a pre-normalized domain
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
190
|
+
* Run the full RDAP lookup flow for a pre-normalized domain via the
|
|
191
|
+
* smartwhois RdapClient. Returns the parsed RDAP fields (as a full
|
|
192
|
+
* IDomainIntelligenceResult) or null if any step fails, the TLD has no
|
|
193
|
+
* RDAP support, or the registry reports the domain as not registered.
|
|
179
194
|
*/
|
|
180
195
|
private async queryRdapLayer(domain: string): Promise<IDomainIntelligenceResult | null> {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
try {
|
|
197
|
+
const rdapRecord = await this.rdapClient.lookupDomain(domain);
|
|
198
|
+
if (!rdapRecord || !rdapRecord.isRegistered) return null;
|
|
199
|
+
return this.mapRdapRecord(domain, rdapRecord);
|
|
200
|
+
} catch (err: any) {
|
|
201
|
+
this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
190
205
|
|
|
191
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Map a smartwhois RDAP domain record onto the IDomainIntelligenceResult
|
|
208
|
+
* shape (RDAP fields only; DNS enrichment fields stay null here).
|
|
209
|
+
*/
|
|
210
|
+
private mapRdapRecord(
|
|
211
|
+
domain: string,
|
|
212
|
+
record: plugins.smartwhois.IRdapDomainRecord,
|
|
213
|
+
): IDomainIntelligenceResult {
|
|
214
|
+
const result = emptyResult(domain);
|
|
215
|
+
result.handle = record.handle ?? null;
|
|
216
|
+
result.status = record.statuses && record.statuses.length > 0 ? record.statuses : null;
|
|
217
|
+
result.registrationDate = record.createdDate ?? null;
|
|
218
|
+
result.expirationDate = record.expiryDate ?? null;
|
|
219
|
+
result.lastChangedDate = record.updatedDate ?? null;
|
|
220
|
+
result.registrarName = record.registrar ?? null;
|
|
221
|
+
result.registrarIanaId = record.registrarIanaId ?? null;
|
|
222
|
+
result.registrantOrg = record.registrantOrg ?? null;
|
|
223
|
+
result.registrantCountry = record.registrantCountry ?? null;
|
|
224
|
+
result.abuseEmail = record.abuseEmail ?? null;
|
|
225
|
+
result.abusePhone = record.abusePhone ?? null;
|
|
226
|
+
result.nameservers =
|
|
227
|
+
record.nameservers && record.nameservers.length > 0 ? record.nameservers : null;
|
|
228
|
+
result.dnssec = record.dnssec ?? null;
|
|
229
|
+
return result;
|
|
192
230
|
}
|
|
193
231
|
|
|
194
232
|
// ─── Normalization & TLD extraction ─────────────────────────────────
|
|
@@ -217,244 +255,6 @@ export class DomainIntelligence {
|
|
|
217
255
|
return ascii;
|
|
218
256
|
}
|
|
219
257
|
|
|
220
|
-
/**
|
|
221
|
-
* Extract the TLD as the last dot-separated label.
|
|
222
|
-
*/
|
|
223
|
-
private extractTld(domain: string): string | null {
|
|
224
|
-
const idx = domain.lastIndexOf('.');
|
|
225
|
-
if (idx < 0 || idx === domain.length - 1) return null;
|
|
226
|
-
return domain.slice(idx + 1);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ─── Bootstrap Subsystem ────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Load and cache the IANA DNS bootstrap file.
|
|
233
|
-
*/
|
|
234
|
-
private async ensureBootstrap(): Promise<void> {
|
|
235
|
-
if (this.bootstrapEntries) return;
|
|
236
|
-
if (this.bootstrapPromise) {
|
|
237
|
-
await this.bootstrapPromise;
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
this.bootstrapPromise = (async () => {
|
|
242
|
-
try {
|
|
243
|
-
const controller = new AbortController();
|
|
244
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
245
|
-
try {
|
|
246
|
-
const response = await fetch(IANA_BOOTSTRAP_DNS_URL, {
|
|
247
|
-
signal: controller.signal,
|
|
248
|
-
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
|
|
249
|
-
});
|
|
250
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
251
|
-
const data = (await response.json()) as { services: [string[], string[]][] };
|
|
252
|
-
|
|
253
|
-
const entries = new Map<string, string>();
|
|
254
|
-
for (const [tlds, urls] of data.services) {
|
|
255
|
-
const baseUrl = urls[0]; // first URL is preferred
|
|
256
|
-
if (!baseUrl) continue;
|
|
257
|
-
const cleanBase = baseUrl.replace(/\/$/, ''); // strip trailing slash
|
|
258
|
-
for (const tld of tlds) {
|
|
259
|
-
entries.set(tld.toLowerCase(), cleanBase);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
this.bootstrapEntries = entries;
|
|
263
|
-
} finally {
|
|
264
|
-
clearTimeout(timeoutId);
|
|
265
|
-
}
|
|
266
|
-
} catch (err: any) {
|
|
267
|
-
this.logger.debug?.(`Failed to load DNS RDAP bootstrap: ${err.message}`);
|
|
268
|
-
this.bootstrapEntries = new Map(); // empty = all RDAP lookups will skip
|
|
269
|
-
}
|
|
270
|
-
})();
|
|
271
|
-
|
|
272
|
-
await this.bootstrapPromise;
|
|
273
|
-
this.bootstrapPromise = null;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Find the RDAP base URL for a given TLD via direct lookup.
|
|
278
|
-
*/
|
|
279
|
-
private matchTld(tld: string): string | null {
|
|
280
|
-
if (!this.bootstrapEntries || this.bootstrapEntries.size === 0) return null;
|
|
281
|
-
return this.bootstrapEntries.get(tld.toLowerCase()) ?? null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ─── RDAP Query ─────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Perform the RDAP HTTP query for a domain.
|
|
288
|
-
*/
|
|
289
|
-
private async queryRdap(domain: string, baseUrl: string): Promise<any | null> {
|
|
290
|
-
const controller = new AbortController();
|
|
291
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
292
|
-
try {
|
|
293
|
-
const response = await fetch(`${baseUrl}/domain/${encodeURIComponent(domain)}`, {
|
|
294
|
-
signal: controller.signal,
|
|
295
|
-
headers: {
|
|
296
|
-
'Accept': 'application/rdap+json',
|
|
297
|
-
'User-Agent': '@push.rocks/smartnetwork',
|
|
298
|
-
},
|
|
299
|
-
});
|
|
300
|
-
if (!response.ok) return null;
|
|
301
|
-
return await response.json();
|
|
302
|
-
} catch (err: any) {
|
|
303
|
-
this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`);
|
|
304
|
-
return null;
|
|
305
|
-
} finally {
|
|
306
|
-
clearTimeout(timeoutId);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ─── RDAP Response Parsing ──────────────────────────────────────────
|
|
311
|
-
|
|
312
|
-
private parseRdapResponse(domain: string, data: any): IDomainIntelligenceResult {
|
|
313
|
-
const result = emptyResult(domain);
|
|
314
|
-
|
|
315
|
-
if (typeof data.handle === 'string') result.handle = data.handle;
|
|
316
|
-
|
|
317
|
-
if (Array.isArray(data.status) && data.status.length > 0) {
|
|
318
|
-
result.status = data.status.filter((s: any): s is string => typeof s === 'string');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Events: registration / expiration / last changed
|
|
322
|
-
if (Array.isArray(data.events)) {
|
|
323
|
-
const events = this.extractEvents(data.events);
|
|
324
|
-
result.registrationDate = events.registration;
|
|
325
|
-
result.expirationDate = events.expiration;
|
|
326
|
-
result.lastChangedDate = events.lastChanged;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Registrar (sponsor) and registrant from entities
|
|
330
|
-
if (Array.isArray(data.entities)) {
|
|
331
|
-
const registrar = this.extractRegistrar(data.entities);
|
|
332
|
-
result.registrarName = registrar.name;
|
|
333
|
-
result.registrarIanaId = registrar.ianaId;
|
|
334
|
-
result.abuseEmail = registrar.abuseEmail;
|
|
335
|
-
result.abusePhone = registrar.abusePhone;
|
|
336
|
-
|
|
337
|
-
const registrant = this.extractRegistrant(data.entities);
|
|
338
|
-
result.registrantOrg = registrant.org;
|
|
339
|
-
result.registrantCountry = registrant.country;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Nameservers
|
|
343
|
-
if (Array.isArray(data.nameservers)) {
|
|
344
|
-
result.nameservers = this.extractNameservers(data.nameservers);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// DNSSEC: secureDNS.delegationSigned
|
|
348
|
-
if (data.secureDNS && typeof data.secureDNS.delegationSigned === 'boolean') {
|
|
349
|
-
result.dnssec = data.secureDNS.delegationSigned;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return result;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Pull registration / expiration / last changed timestamps from an
|
|
357
|
-
* RDAP `events` array.
|
|
358
|
-
*/
|
|
359
|
-
private extractEvents(events: any[]): {
|
|
360
|
-
registration: string | null;
|
|
361
|
-
expiration: string | null;
|
|
362
|
-
lastChanged: string | null;
|
|
363
|
-
} {
|
|
364
|
-
let registration: string | null = null;
|
|
365
|
-
let expiration: string | null = null;
|
|
366
|
-
let lastChanged: string | null = null;
|
|
367
|
-
|
|
368
|
-
for (const ev of events) {
|
|
369
|
-
const action = typeof ev?.eventAction === 'string' ? ev.eventAction.toLowerCase() : '';
|
|
370
|
-
const date = typeof ev?.eventDate === 'string' ? ev.eventDate : null;
|
|
371
|
-
if (!date) continue;
|
|
372
|
-
if (action === 'registration') registration = date;
|
|
373
|
-
else if (action === 'expiration') expiration = date;
|
|
374
|
-
else if (action === 'last changed') lastChanged = date;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return { registration, expiration, lastChanged };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Extract registrar identity (name, IANA ID) and a nested abuse contact.
|
|
382
|
-
*/
|
|
383
|
-
private extractRegistrar(entities: any[]): {
|
|
384
|
-
name: string | null;
|
|
385
|
-
ianaId: number | null;
|
|
386
|
-
abuseEmail: string | null;
|
|
387
|
-
abusePhone: string | null;
|
|
388
|
-
} {
|
|
389
|
-
let name: string | null = null;
|
|
390
|
-
let ianaId: number | null = null;
|
|
391
|
-
let abuseEmail: string | null = null;
|
|
392
|
-
let abusePhone: string | null = null;
|
|
393
|
-
|
|
394
|
-
for (const entity of entities) {
|
|
395
|
-
const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
|
|
396
|
-
if (!roles.includes('registrar')) continue;
|
|
397
|
-
|
|
398
|
-
name = this.extractVcardFn(entity);
|
|
399
|
-
|
|
400
|
-
// IANA Registrar ID lives in publicIds[]
|
|
401
|
-
if (Array.isArray(entity.publicIds)) {
|
|
402
|
-
for (const pid of entity.publicIds) {
|
|
403
|
-
if (pid && typeof pid === 'object' && pid.type === 'IANA Registrar ID') {
|
|
404
|
-
const parsed = parseInt(String(pid.identifier), 10);
|
|
405
|
-
if (!isNaN(parsed)) ianaId = parsed;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Abuse contact: nested entity with role "abuse"
|
|
411
|
-
if (Array.isArray(entity.entities)) {
|
|
412
|
-
for (const sub of entity.entities) {
|
|
413
|
-
const subRoles: string[] = Array.isArray(sub?.roles) ? sub.roles : [];
|
|
414
|
-
if (subRoles.includes('abuse')) {
|
|
415
|
-
if (!abuseEmail) abuseEmail = this.extractVcardEmail(sub);
|
|
416
|
-
if (!abusePhone) abusePhone = this.extractVcardTel(sub);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
break; // first registrar wins
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return { name, ianaId, abuseEmail, abusePhone };
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Extract registrant org/country from entities array.
|
|
429
|
-
*/
|
|
430
|
-
private extractRegistrant(entities: any[]): {
|
|
431
|
-
org: string | null;
|
|
432
|
-
country: string | null;
|
|
433
|
-
} {
|
|
434
|
-
for (const entity of entities) {
|
|
435
|
-
const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
|
|
436
|
-
if (!roles.includes('registrant')) continue;
|
|
437
|
-
const org = this.extractVcardFn(entity);
|
|
438
|
-
const country = this.extractVcardCountry(entity);
|
|
439
|
-
return { org, country };
|
|
440
|
-
}
|
|
441
|
-
return { org: null, country: null };
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Map an RDAP nameservers[] array to a list of lowercased ldhName strings.
|
|
446
|
-
*/
|
|
447
|
-
private extractNameservers(nameservers: any[]): string[] | null {
|
|
448
|
-
const out: string[] = [];
|
|
449
|
-
for (const ns of nameservers) {
|
|
450
|
-
const ldh = ns?.ldhName;
|
|
451
|
-
if (typeof ldh === 'string' && ldh.length > 0) {
|
|
452
|
-
out.push(ldh.toLowerCase());
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return out.length > 0 ? out : null;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
258
|
// ─── DNS Layer ──────────────────────────────────────────────────────
|
|
459
259
|
|
|
460
260
|
/**
|
|
@@ -464,8 +264,8 @@ export class DomainIntelligence {
|
|
|
464
264
|
* field either populated or null.
|
|
465
265
|
*
|
|
466
266
|
* If a shared dnsClient was injected via constructor options, it is
|
|
467
|
-
* reused and NOT
|
|
468
|
-
* a short-lived client is created and
|
|
267
|
+
* reused and NOT terminated (ownership stays with the injector). Otherwise
|
|
268
|
+
* a short-lived client is created and terminated in finally.
|
|
469
269
|
*/
|
|
470
270
|
private async queryDnsLayer(domain: string): Promise<{
|
|
471
271
|
nameservers: string[] | null;
|
|
@@ -475,45 +275,66 @@ export class DomainIntelligence {
|
|
|
475
275
|
txtRecords: string[] | null;
|
|
476
276
|
soaRecord: string | null;
|
|
477
277
|
} | null> {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
278
|
+
const query = async (dnsClientArg: TSmartdnsClient) => {
|
|
279
|
+
try {
|
|
280
|
+
const [nsRes, aRes, aaaaRes, mxRes, txtRes, soaRes] = await Promise.allSettled([
|
|
281
|
+
dnsClientArg.getNameServers(domain),
|
|
282
|
+
dnsClientArg.getRecordsA(domain),
|
|
283
|
+
dnsClientArg.getRecordsAAAA(domain),
|
|
284
|
+
dnsClientArg.getRecords(domain, 'MX'),
|
|
285
|
+
dnsClientArg.getRecordsTxt(domain),
|
|
286
|
+
// 'SOA' is in smartdns's runtime dnsTypeMap but missing from the
|
|
287
|
+
// TDnsRecordType union in @tsclass/tsclass — cast to bypass the
|
|
288
|
+
// stale type definition.
|
|
289
|
+
dnsClientArg.getRecords(domain, 'SOA' as any),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
nameservers: this.parseNsResult(nsRes),
|
|
294
|
+
resolvedIpv4: this.dnsValuesOrNull(aRes),
|
|
295
|
+
resolvedIpv6: this.dnsValuesOrNull(aaaaRes),
|
|
296
|
+
mxRecords: this.parseMxRecords(mxRes),
|
|
297
|
+
txtRecords: this.dnsValuesOrNull(txtRes),
|
|
298
|
+
soaRecord: this.dnsFirstValueOrNull(soaRes),
|
|
299
|
+
};
|
|
300
|
+
} catch (err: any) {
|
|
301
|
+
this.logger.debug?.(`DNS layer failed for ${domain}: ${err.message}`);
|
|
302
|
+
return null;
|
|
487
303
|
}
|
|
304
|
+
};
|
|
488
305
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
mxRecords: this.parseMxRecords(mxRes),
|
|
506
|
-
txtRecords: this.dnsValuesOrNull(txtRes),
|
|
507
|
-
soaRecord: this.dnsFirstValueOrNull(soaRes),
|
|
508
|
-
};
|
|
509
|
-
} catch (err: any) {
|
|
510
|
-
this.logger.debug?.(`DNS layer failed for ${domain}: ${err.message}`);
|
|
511
|
-
return null;
|
|
512
|
-
} finally {
|
|
513
|
-
// Only destroy clients we created ourselves; leave injected ones alone.
|
|
514
|
-
if (!external && dnsClient) {
|
|
515
|
-
dnsClient.destroy();
|
|
306
|
+
if (this.sharedDnsClient) {
|
|
307
|
+
return query(this.sharedDnsClient);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return this.enqueueDnsLifecycle(async () => {
|
|
311
|
+
await this.terminateOwnedDnsClient();
|
|
312
|
+
const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
|
313
|
+
strategy: 'prefer-system',
|
|
314
|
+
allowDohFallback: true,
|
|
315
|
+
timeoutMs: this.timeout,
|
|
316
|
+
});
|
|
317
|
+
this.ownedDnsClient = dnsClient;
|
|
318
|
+
try {
|
|
319
|
+
return await query(dnsClient);
|
|
320
|
+
} finally {
|
|
321
|
+
await this.terminateOwnedDnsClient();
|
|
516
322
|
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private enqueueDnsLifecycle<T>(operationArg: () => Promise<T>): Promise<T> {
|
|
327
|
+
const result = this.dnsLifecycleQueue.then(operationArg);
|
|
328
|
+
this.dnsLifecycleQueue = result.then(() => {}, () => {});
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async terminateOwnedDnsClient(): Promise<void> {
|
|
333
|
+
const dnsClient = this.ownedDnsClient;
|
|
334
|
+
if (!dnsClient) return;
|
|
335
|
+
await dnsClient.terminate();
|
|
336
|
+
if (this.ownedDnsClient === dnsClient) {
|
|
337
|
+
this.ownedDnsClient = null;
|
|
517
338
|
}
|
|
518
339
|
}
|
|
519
340
|
|
|
@@ -575,85 +396,4 @@ export class DomainIntelligence {
|
|
|
575
396
|
}
|
|
576
397
|
return out.length > 0 ? out : null;
|
|
577
398
|
}
|
|
578
|
-
|
|
579
|
-
// ─── vCard helpers (duplicated from IpIntelligence) ─────────────────
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Extract the 'fn' (formatted name) from an entity's vcardArray
|
|
583
|
-
*/
|
|
584
|
-
private extractVcardFn(entity: any): string | null {
|
|
585
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
586
|
-
const properties = entity.vcardArray[1];
|
|
587
|
-
if (!Array.isArray(properties)) return null;
|
|
588
|
-
|
|
589
|
-
for (const prop of properties) {
|
|
590
|
-
if (Array.isArray(prop) && prop[0] === 'fn') {
|
|
591
|
-
return prop[3] || null;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Extract email from an entity's vcardArray
|
|
599
|
-
*/
|
|
600
|
-
private extractVcardEmail(entity: any): string | null {
|
|
601
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
602
|
-
const properties = entity.vcardArray[1];
|
|
603
|
-
if (!Array.isArray(properties)) return null;
|
|
604
|
-
|
|
605
|
-
for (const prop of properties) {
|
|
606
|
-
if (Array.isArray(prop) && prop[0] === 'email') {
|
|
607
|
-
return prop[3] || null;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
return null;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Extract telephone number from an entity's vcardArray
|
|
615
|
-
*/
|
|
616
|
-
private extractVcardTel(entity: any): string | null {
|
|
617
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
618
|
-
const properties = entity.vcardArray[1];
|
|
619
|
-
if (!Array.isArray(properties)) return null;
|
|
620
|
-
|
|
621
|
-
for (const prop of properties) {
|
|
622
|
-
if (Array.isArray(prop) && prop[0] === 'tel') {
|
|
623
|
-
// tel value can be a string or a uri like "tel:+1.5555555555"
|
|
624
|
-
const value = prop[3];
|
|
625
|
-
if (typeof value === 'string') {
|
|
626
|
-
return value.startsWith('tel:') ? value.slice(4) : value;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Extract country from an entity's vcardArray address field
|
|
635
|
-
*/
|
|
636
|
-
private extractVcardCountry(entity: any): string | null {
|
|
637
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
638
|
-
const properties = entity.vcardArray[1];
|
|
639
|
-
if (!Array.isArray(properties)) return null;
|
|
640
|
-
|
|
641
|
-
for (const prop of properties) {
|
|
642
|
-
if (Array.isArray(prop) && prop[0] === 'adr') {
|
|
643
|
-
// The label parameter often contains the full address with country at the end
|
|
644
|
-
const label = prop[1]?.label;
|
|
645
|
-
if (typeof label === 'string') {
|
|
646
|
-
const lines = label.split('\n');
|
|
647
|
-
const lastLine = lines[lines.length - 1]?.trim();
|
|
648
|
-
if (lastLine && lastLine.length > 1) return lastLine;
|
|
649
|
-
}
|
|
650
|
-
// Also check the structured value (7-element array, last element is country)
|
|
651
|
-
const value = prop[3];
|
|
652
|
-
if (Array.isArray(value) && value.length >= 7 && value[6]) {
|
|
653
|
-
return value[6];
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
return null;
|
|
658
|
-
}
|
|
659
399
|
}
|