fix(ipintelligence): handle flat geolocation MMDB schema and clean up DNS client lifecycle
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-26 - 4.5.1 - fix(ipintelligence)
|
||||||
|
handle flat geolocation MMDB schema and clean up DNS client lifecycle
|
||||||
|
|
||||||
|
- update IP intelligence lookups to read geolocation fields from the flat @ip-location-db schema instead of MaxMind nested city records
|
||||||
|
- destroy the Team Cymru DNS client after queries to avoid leaking resources
|
||||||
|
- adjust IP intelligence tests to validate countryCode-based geolocation and RDAP fields against current data sources
|
||||||
|
|
||||||
## 2026-03-26 - 4.5.0 - feat(smartnetwork)
|
## 2026-03-26 - 4.5.0 - feat(smartnetwork)
|
||||||
add Rust-powered network diagnostics bridge and IP intelligence lookups
|
add Rust-powered network diagnostics bridge and IP intelligence lookups
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ tap.test('should create a SmartNetwork instance', async () => {
|
|||||||
expect(testSmartNetwork).toBeInstanceOf(smartnetwork.SmartNetwork);
|
expect(testSmartNetwork).toBeInstanceOf(smartnetwork.SmartNetwork);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should get IP intelligence for 8.8.8.8 (Google)', async () => {
|
||||||
|
const result = await testSmartNetwork.getIpIntelligence('8.8.8.8');
|
||||||
|
console.log('IP Intelligence for 8.8.8.8:', JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
// Google's ASN is 15169
|
||||||
|
expect(result.asn).toEqual(15169);
|
||||||
|
expect(result.asnOrg).toBeTruthy();
|
||||||
|
|
||||||
|
// Geolocation
|
||||||
|
expect(result.countryCode).toEqual('US');
|
||||||
|
expect(result.latitude).not.toBeNull();
|
||||||
|
expect(result.longitude).not.toBeNull();
|
||||||
|
|
||||||
|
// RDAP registration
|
||||||
|
expect(result.registrantOrg).toBeTruthy();
|
||||||
|
expect(result.networkRange).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('should get IP intelligence for 1.1.1.1 (Cloudflare)', async () => {
|
tap.test('should get IP intelligence for 1.1.1.1 (Cloudflare)', async () => {
|
||||||
const result = await testSmartNetwork.getIpIntelligence('1.1.1.1');
|
const result = await testSmartNetwork.getIpIntelligence('1.1.1.1');
|
||||||
console.log('IP Intelligence for 1.1.1.1:', JSON.stringify(result, null, 2));
|
console.log('IP Intelligence for 1.1.1.1:', JSON.stringify(result, null, 2));
|
||||||
@@ -16,24 +34,11 @@ tap.test('should get IP intelligence for 1.1.1.1 (Cloudflare)', async () => {
|
|||||||
expect(result.asn).toEqual(13335);
|
expect(result.asn).toEqual(13335);
|
||||||
expect(result.asnOrg).toBeTruthy();
|
expect(result.asnOrg).toBeTruthy();
|
||||||
|
|
||||||
// Geolocation should be present
|
|
||||||
expect(result.country).toBeTruthy();
|
|
||||||
expect(result.countryCode).toBeTruthy();
|
|
||||||
expect(result.latitude).not.toBeNull();
|
|
||||||
expect(result.longitude).not.toBeNull();
|
|
||||||
|
|
||||||
// RDAP registration data should be present
|
// RDAP registration data should be present
|
||||||
expect(result.networkRange).toBeTruthy();
|
expect(result.networkRange).toBeTruthy();
|
||||||
});
|
expect(result.registrantCountry).toBeTruthy();
|
||||||
|
|
||||||
tap.test('should get IP intelligence for 8.8.8.8 (Google)', async () => {
|
// Note: 1.1.1.1 is anycast — city-level geo may be null in GeoLite2
|
||||||
const result = await testSmartNetwork.getIpIntelligence('8.8.8.8');
|
|
||||||
console.log('IP Intelligence for 8.8.8.8:', JSON.stringify(result, null, 2));
|
|
||||||
|
|
||||||
// Google's ASN is 15169
|
|
||||||
expect(result.asn).toEqual(15169);
|
|
||||||
expect(result.country).toBeTruthy();
|
|
||||||
expect(result.countryCode).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should get IP intelligence for own public IP', async () => {
|
tap.test('should get IP intelligence for own public IP', async () => {
|
||||||
@@ -42,7 +47,7 @@ tap.test('should get IP intelligence for own public IP', async () => {
|
|||||||
const result = await testSmartNetwork.getIpIntelligence(ips.v4);
|
const result = await testSmartNetwork.getIpIntelligence(ips.v4);
|
||||||
console.log(`IP Intelligence for own IP (${ips.v4}):`, JSON.stringify(result, null, 2));
|
console.log(`IP Intelligence for own IP (${ips.v4}):`, JSON.stringify(result, null, 2));
|
||||||
expect(result.asn).toBeTypeofNumber();
|
expect(result.asn).toBeTypeofNumber();
|
||||||
expect(result.country).toBeTruthy();
|
expect(result.countryCode).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,12 +60,11 @@ tap.test('should handle invalid IP gracefully', async () => {
|
|||||||
|
|
||||||
tap.test('should use cache when cacheTtl is set', async () => {
|
tap.test('should use cache when cacheTtl is set', async () => {
|
||||||
const cached = new smartnetwork.SmartNetwork({ cacheTtl: 60000 });
|
const cached = new smartnetwork.SmartNetwork({ cacheTtl: 60000 });
|
||||||
const r1 = await cached.getIpIntelligence('1.1.1.1');
|
const r1 = await cached.getIpIntelligence('8.8.8.8');
|
||||||
const r2 = await cached.getIpIntelligence('1.1.1.1');
|
const r2 = await cached.getIpIntelligence('8.8.8.8');
|
||||||
// Second call should return the same cached result
|
// Second call should return the same cached result
|
||||||
expect(r1.asn).toEqual(r2.asn);
|
expect(r1.asn).toEqual(r2.asn);
|
||||||
expect(r1.country).toEqual(r2.country);
|
expect(r1.countryCode).toEqual(r2.countryCode);
|
||||||
expect(r1.city).toEqual(r2.city);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartnetwork',
|
name: '@push.rocks/smartnetwork',
|
||||||
version: '4.5.0',
|
version: '4.5.1',
|
||||||
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
|
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,21 @@ import * as plugins from './smartnetwork.plugins.js';
|
|||||||
import { getLogger } from './logging.js';
|
import { getLogger } from './logging.js';
|
||||||
|
|
||||||
// MaxMind types re-exported from mmdb-lib via maxmind
|
// MaxMind types re-exported from mmdb-lib via maxmind
|
||||||
import type { CityResponse, AsnResponse, Reader } from 'maxmind';
|
import type { AsnResponse, Reader, Response } from 'maxmind';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The @ip-location-db MMDB files use a flat schema instead of the standard MaxMind nested format.
|
||||||
|
*/
|
||||||
|
interface IIpLocationDbCityRecord {
|
||||||
|
city?: string;
|
||||||
|
country_code?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
postcode?: string;
|
||||||
|
state1?: string;
|
||||||
|
state2?: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified result from all IP intelligence layers
|
* Unified result from all IP intelligence layers
|
||||||
@@ -70,7 +84,7 @@ export class IpIntelligence {
|
|||||||
private readonly timeout: number;
|
private readonly timeout: number;
|
||||||
|
|
||||||
// MaxMind readers (lazily initialized)
|
// MaxMind readers (lazily initialized)
|
||||||
private cityReader: Reader<CityResponse> | null = null;
|
private cityReader: Reader<IIpLocationDbCityRecord & Response> | null = null;
|
||||||
private asnReader: Reader<AsnResponse> | null = null;
|
private asnReader: Reader<AsnResponse> | null = null;
|
||||||
private lastFetchTime = 0;
|
private lastFetchTime = 0;
|
||||||
private refreshPromise: Promise<void> | null = null;
|
private refreshPromise: Promise<void> | null = null;
|
||||||
@@ -371,11 +385,12 @@ export class IpIntelligence {
|
|||||||
* Response: "ASN | prefix | CC | rir | date"
|
* Response: "ASN | prefix | CC | rir | date"
|
||||||
*/
|
*/
|
||||||
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
|
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
|
||||||
|
let dnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null = null;
|
||||||
try {
|
try {
|
||||||
const reversed = ip.split('.').reverse().join('.');
|
const reversed = ip.split('.').reverse().join('.');
|
||||||
const queryName = `${reversed}.origin.asn.cymru.com`;
|
const queryName = `${reversed}.origin.asn.cymru.com`;
|
||||||
|
|
||||||
const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
||||||
strategy: 'prefer-system',
|
strategy: 'prefer-system',
|
||||||
allowDohFallback: true,
|
allowDohFallback: true,
|
||||||
timeoutMs: this.timeout,
|
timeoutMs: this.timeout,
|
||||||
@@ -402,6 +417,10 @@ export class IpIntelligence {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
|
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (dnsClient) {
|
||||||
|
dnsClient.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +461,7 @@ export class IpIntelligence {
|
|||||||
this.fetchBuffer(ASN_MMDB_URL),
|
this.fetchBuffer(ASN_MMDB_URL),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.cityReader = new plugins.maxmind.Reader<CityResponse>(cityBuffer);
|
this.cityReader = new plugins.maxmind.Reader<IIpLocationDbCityRecord & Response>(cityBuffer);
|
||||||
this.asnReader = new plugins.maxmind.Reader<AsnResponse>(asnBuffer);
|
this.asnReader = new plugins.maxmind.Reader<AsnResponse>(asnBuffer);
|
||||||
this.lastFetchTime = Date.now();
|
this.lastFetchTime = Date.now();
|
||||||
this.logger.info?.('MaxMind MMDB databases loaded into memory');
|
this.logger.info?.('MaxMind MMDB databases loaded into memory');
|
||||||
@@ -495,17 +514,17 @@ export class IpIntelligence {
|
|||||||
let asn: number | null = null;
|
let asn: number | null = null;
|
||||||
let asnOrg: string | null = null;
|
let asnOrg: string | null = null;
|
||||||
|
|
||||||
// City lookup
|
// City lookup — @ip-location-db uses flat schema: city, country_code, latitude, longitude, etc.
|
||||||
try {
|
try {
|
||||||
const cityResult = this.cityReader.get(ip);
|
const cityResult = this.cityReader.get(ip);
|
||||||
if (cityResult) {
|
if (cityResult) {
|
||||||
country = cityResult.country?.names?.en || null;
|
countryCode = cityResult.country_code || null;
|
||||||
countryCode = cityResult.country?.iso_code || null;
|
city = cityResult.city || null;
|
||||||
city = cityResult.city?.names?.en || null;
|
latitude = cityResult.latitude ?? null;
|
||||||
latitude = cityResult.location?.latitude ?? null;
|
longitude = cityResult.longitude ?? null;
|
||||||
longitude = cityResult.location?.longitude ?? null;
|
timezone = cityResult.timezone || null;
|
||||||
accuracyRadius = cityResult.location?.accuracy_radius ?? null;
|
// @ip-location-db does not include country name or accuracy_radius
|
||||||
timezone = cityResult.location?.time_zone || null;
|
// We leave country null (countryCode is available)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`);
|
this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user