@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.
- package/{npmextra.json → .smartconfig.json} +17 -5
- package/dist_rust/rustnetwork_linux_amd64 +0 -0
- package/dist_rust/rustnetwork_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/helpers/stats.js +1 -1
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/smartnetwork.classes.cloudflarespeed.js +32 -11
- package/dist_ts/smartnetwork.classes.ipintelligence.d.ts +101 -0
- package/dist_ts/smartnetwork.classes.ipintelligence.js +442 -0
- package/dist_ts/smartnetwork.classes.publicip.js +1 -1
- package/dist_ts/smartnetwork.classes.rustbridge.d.ts +100 -0
- package/dist_ts/smartnetwork.classes.rustbridge.js +98 -0
- package/dist_ts/smartnetwork.classes.smartnetwork.d.ts +40 -32
- package/dist_ts/smartnetwork.classes.smartnetwork.js +132 -152
- package/dist_ts/smartnetwork.plugins.d.ts +7 -10
- package/dist_ts/smartnetwork.plugins.js +9 -13
- package/package.json +19 -18
- package/readme.hints.md +60 -34
- package/readme.md +22 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers/stats.ts +1 -1
- package/ts/index.ts +3 -0
- package/ts/smartnetwork.classes.cloudflarespeed.ts +32 -16
- package/ts/smartnetwork.classes.ipintelligence.ts +542 -0
- package/ts/smartnetwork.classes.publicip.ts +2 -2
- package/ts/smartnetwork.classes.rustbridge.ts +181 -0
- package/ts/smartnetwork.classes.smartnetwork.ts +156 -164
- package/ts/smartnetwork.plugins.ts +8 -12
|
@@ -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
|
}
|