feat(smartnetwork): add Rust-powered network diagnostics bridge and IP intelligence lookups
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartnetwork',
|
||||
version: '4.4.0',
|
||||
version: '4.5.0',
|
||||
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function quartile(values: number[], percentile: number) {
|
||||
|
||||
export function jitter(values: number[]) {
|
||||
// Average distance between consecutive latency measurements...
|
||||
let jitters = [];
|
||||
let jitters: number[] = [];
|
||||
|
||||
for (let i = 0; i < values.length - 1; i += 1) {
|
||||
jitters.push(Math.abs(values[i] - values[i + 1]));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from './smartnetwork.classes.smartnetwork.js';
|
||||
export type { SmartNetworkOptions, Hop } from './smartnetwork.classes.smartnetwork.js';
|
||||
export { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
|
||||
export { PublicIp } from './smartnetwork.classes.publicip.js';
|
||||
export { IpIntelligence } from './smartnetwork.classes.ipintelligence.js';
|
||||
export type { IIpIntelligenceResult, IIpIntelligenceOptions } from './smartnetwork.classes.ipintelligence.js';
|
||||
export { setLogger, getLogger } from './logging.js';
|
||||
export { NetworkError, TimeoutError } from './errors.js';
|
||||
|
||||
@@ -53,7 +53,7 @@ export class CloudflareSpeed {
|
||||
const t5 = await measureDownloadParallel(100001000, 1);
|
||||
downloadTests = [...t1, ...t2, ...t3, ...t4, ...t5];
|
||||
}
|
||||
const speedDownload = stats.quartile(downloadTests, 0.9).toFixed(2);
|
||||
const speedDownload = downloadTests.length > 0 ? stats.quartile(downloadTests, 0.9).toFixed(2) : '0.00';
|
||||
|
||||
// lets test the upload speed with configurable parallel streams
|
||||
const measureUploadParallel = (bytes: number, iterations: number) => {
|
||||
@@ -84,7 +84,7 @@ export class CloudflareSpeed {
|
||||
const u3 = await measureUploadParallel(1001000, 8);
|
||||
uploadTests = [...u1, ...u2, ...u3];
|
||||
}
|
||||
const speedUpload = stats.quartile(uploadTests, 0.9).toFixed(2);
|
||||
const speedUpload = uploadTests.length > 0 ? stats.quartile(uploadTests, 0.9).toFixed(2) : '0.00';
|
||||
|
||||
return {
|
||||
...latency,
|
||||
@@ -147,8 +147,14 @@ export class CloudflareSpeed {
|
||||
for (let i = 0; i < iterations; i += 1) {
|
||||
await this.upload(bytes).then(
|
||||
async (response) => {
|
||||
const transferTime = response[6];
|
||||
measurements.push(await this.measureSpeed(bytes, transferTime));
|
||||
// Prefer server-timing duration; fall back to client-side transfer time
|
||||
let transferTime = response[6];
|
||||
if (!transferTime || !isFinite(transferTime)) {
|
||||
transferTime = response[5] - response[4]; // ended - ttfb
|
||||
}
|
||||
if (transferTime > 0) {
|
||||
measurements.push(await this.measureSpeed(bytes, transferTime));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
getLogger().error('Error measuring upload chunk:', error);
|
||||
@@ -164,17 +170,22 @@ export class CloudflareSpeed {
|
||||
}
|
||||
|
||||
public async fetchServerLocations(): Promise<{ [key: string]: string }> {
|
||||
const res = JSON.parse(await this.get('speed.cloudflare.com', '/locations')) as Array<{
|
||||
iata: string;
|
||||
city: string;
|
||||
}>;
|
||||
return res.reduce(
|
||||
(data: Record<string, string>, optionsArg) => {
|
||||
data[optionsArg.iata] = optionsArg.city;
|
||||
return data;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
try {
|
||||
const raw = await this.get('speed.cloudflare.com', '/locations');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return (parsed as Array<{ iata: string; city: string }>).reduce(
|
||||
(data: Record<string, string>, entry) => {
|
||||
data[entry.iata] = entry.city;
|
||||
return data;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
public async get(hostname: string, path: string): Promise<string> {
|
||||
@@ -259,7 +270,12 @@ export class CloudflareSpeed {
|
||||
sslHandshake,
|
||||
ttfb,
|
||||
ended,
|
||||
parseFloat((res.headers['server-timing'] as string).slice(22)),
|
||||
(() => {
|
||||
const serverTiming = res.headers['server-timing'] as string | undefined;
|
||||
if (!serverTiming) return 0;
|
||||
const match = serverTiming.match(/dur=([\d.]+)/);
|
||||
return match ? parseFloat(match[1]) : parseFloat(serverTiming.slice(22)) || 0;
|
||||
})(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
542
ts/smartnetwork.classes.ipintelligence.ts
Normal file
542
ts/smartnetwork.classes.ipintelligence.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import { getLogger } from './logging.js';
|
||||
|
||||
// MaxMind types re-exported from mmdb-lib via maxmind
|
||||
import type { CityResponse, AsnResponse, Reader } from 'maxmind';
|
||||
|
||||
/**
|
||||
* Unified result from all IP intelligence layers
|
||||
*/
|
||||
export interface IIpIntelligenceResult {
|
||||
// ASN (Team Cymru primary, MaxMind fallback)
|
||||
asn: number | null;
|
||||
asnOrg: string | null;
|
||||
|
||||
// Registration (RDAP)
|
||||
registrantOrg: string | null;
|
||||
registrantCountry: string | null;
|
||||
networkRange: string | null;
|
||||
abuseContact: string | null;
|
||||
|
||||
// Geolocation (MaxMind GeoLite2 City)
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadius: number | null;
|
||||
timezone: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for IpIntelligence
|
||||
*/
|
||||
export interface IIpIntelligenceOptions {
|
||||
/** Max age (ms) before triggering background MMDB refresh. Default: 7 days */
|
||||
dbMaxAge?: number;
|
||||
/** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// CDN URLs for GeoLite2 MMDB files (served via jsDelivr from npm packages)
|
||||
const CITY_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb';
|
||||
const ASN_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-asn-mmdb/geolite2-asn-ipv4.mmdb';
|
||||
|
||||
// IANA bootstrap for RDAP
|
||||
const IANA_BOOTSTRAP_IPV4_URL = 'https://data.iana.org/rdap/ipv4.json';
|
||||
|
||||
const DEFAULT_DB_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const DEFAULT_TIMEOUT = 5000;
|
||||
|
||||
/**
|
||||
* Parsed IANA bootstrap entry: a CIDR prefix mapped to an RDAP base URL
|
||||
*/
|
||||
interface IBootstrapEntry {
|
||||
prefix: string;
|
||||
prefixNum: number; // numeric representation of the network address
|
||||
maskBits: number;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IpIntelligence provides IP address intelligence by combining three data sources:
|
||||
* - RDAP (direct to RIRs) for registration/org data
|
||||
* - Team Cymru DNS for ASN
|
||||
* - MaxMind GeoLite2 (in-memory MMDB) for geolocation
|
||||
*/
|
||||
export class IpIntelligence {
|
||||
private readonly logger = getLogger();
|
||||
private readonly dbMaxAge: number;
|
||||
private readonly timeout: number;
|
||||
|
||||
// MaxMind readers (lazily initialized)
|
||||
private cityReader: Reader<CityResponse> | null = null;
|
||||
private asnReader: Reader<AsnResponse> | null = null;
|
||||
private lastFetchTime = 0;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
|
||||
// RDAP bootstrap cache
|
||||
private bootstrapEntries: IBootstrapEntry[] | null = null;
|
||||
private bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(options?: IIpIntelligenceOptions) {
|
||||
this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
|
||||
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive IP intelligence for the given IP address.
|
||||
* Runs RDAP, Team Cymru DNS, and MaxMind lookups in parallel.
|
||||
*/
|
||||
public async getIntelligence(ip: string): Promise<IIpIntelligenceResult> {
|
||||
const result: IIpIntelligenceResult = {
|
||||
asn: null,
|
||||
asnOrg: null,
|
||||
registrantOrg: null,
|
||||
registrantCountry: null,
|
||||
networkRange: null,
|
||||
abuseContact: null,
|
||||
country: null,
|
||||
countryCode: null,
|
||||
city: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadius: null,
|
||||
timezone: null,
|
||||
};
|
||||
|
||||
// Run all three layers in parallel
|
||||
const [rdapResult, cymruResult, maxmindResult] = await Promise.allSettled([
|
||||
this.queryRdap(ip),
|
||||
this.queryTeamCymru(ip),
|
||||
this.queryMaxMind(ip),
|
||||
]);
|
||||
|
||||
// Merge RDAP results
|
||||
if (rdapResult.status === 'fulfilled' && rdapResult.value) {
|
||||
const rdap = rdapResult.value;
|
||||
result.registrantOrg = rdap.registrantOrg;
|
||||
result.registrantCountry = rdap.registrantCountry;
|
||||
result.networkRange = rdap.networkRange;
|
||||
result.abuseContact = rdap.abuseContact;
|
||||
}
|
||||
|
||||
// Merge Team Cymru results (primary for ASN)
|
||||
if (cymruResult.status === 'fulfilled' && cymruResult.value) {
|
||||
const cymru = cymruResult.value;
|
||||
result.asn = cymru.asn;
|
||||
}
|
||||
|
||||
// Merge MaxMind results
|
||||
if (maxmindResult.status === 'fulfilled' && maxmindResult.value) {
|
||||
const mm = maxmindResult.value;
|
||||
result.country = mm.country;
|
||||
result.countryCode = mm.countryCode;
|
||||
result.city = mm.city;
|
||||
result.latitude = mm.latitude;
|
||||
result.longitude = mm.longitude;
|
||||
result.accuracyRadius = mm.accuracyRadius;
|
||||
result.timezone = mm.timezone;
|
||||
|
||||
// Use MaxMind ASN as fallback if Team Cymru failed
|
||||
if (result.asn === null && mm.asn !== null) {
|
||||
result.asn = mm.asn;
|
||||
}
|
||||
if (mm.asnOrg) {
|
||||
result.asnOrg = mm.asnOrg;
|
||||
}
|
||||
}
|
||||
|
||||
// If we got ASN from Team Cymru but not org, and MaxMind didn't provide org either,
|
||||
// the asnOrg remains null (we don't do an additional lookup)
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── RDAP Subsystem ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and cache the IANA RDAP bootstrap file
|
||||
*/
|
||||
private async ensureBootstrap(): Promise<void> {
|
||||
if (this.bootstrapEntries) return;
|
||||
if (this.bootstrapPromise) {
|
||||
await this.bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bootstrapPromise = (async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
try {
|
||||
const response = await fetch(IANA_BOOTSTRAP_IPV4_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json() as { services: [string[], string[]][] };
|
||||
|
||||
const entries: IBootstrapEntry[] = [];
|
||||
for (const [prefixes, urls] of data.services) {
|
||||
const baseUrl = urls[0]; // first URL is preferred
|
||||
for (const prefix of prefixes) {
|
||||
const [network, bits] = prefix.split('/');
|
||||
entries.push({
|
||||
prefix,
|
||||
prefixNum: this.ipToNumber(network),
|
||||
maskBits: parseInt(bits, 10),
|
||||
baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by mask bits descending for longest-prefix match
|
||||
entries.sort((a, b) => b.maskBits - a.maskBits);
|
||||
this.bootstrapEntries = entries;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`Failed to load RDAP bootstrap: ${err.message}`);
|
||||
this.bootstrapEntries = []; // empty = all RDAP lookups will skip
|
||||
}
|
||||
})();
|
||||
|
||||
await this.bootstrapPromise;
|
||||
this.bootstrapPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the RDAP base URL for a given IP via longest-prefix match
|
||||
*/
|
||||
private matchRir(ip: string): string | null {
|
||||
if (!this.bootstrapEntries || this.bootstrapEntries.length === 0) return null;
|
||||
|
||||
const ipNum = this.ipToNumber(ip);
|
||||
|
||||
for (const entry of this.bootstrapEntries) {
|
||||
const mask = (0xFFFFFFFF << (32 - entry.maskBits)) >>> 0;
|
||||
if ((ipNum & mask) === (entry.prefixNum & mask)) {
|
||||
return entry.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query RDAP for registration data
|
||||
*/
|
||||
private async queryRdap(ip: string): Promise<{
|
||||
registrantOrg: string | null;
|
||||
registrantCountry: string | null;
|
||||
networkRange: string | null;
|
||||
abuseContact: string | null;
|
||||
} | null> {
|
||||
await this.ensureBootstrap();
|
||||
const baseUrl = this.matchRir(ip);
|
||||
if (!baseUrl) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/ip/${ip}`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/rdap+json',
|
||||
'User-Agent': '@push.rocks/smartnetwork',
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as any;
|
||||
|
||||
let registrantOrg: string | null = null;
|
||||
let registrantCountry: string | null = data.country || null;
|
||||
let abuseContact: string | null = null;
|
||||
|
||||
// Parse network range
|
||||
let networkRange: string | null = null;
|
||||
if (data.cidr0_cidrs && data.cidr0_cidrs.length > 0) {
|
||||
const cidr = data.cidr0_cidrs[0];
|
||||
networkRange = `${cidr.v4prefix || cidr.v6prefix}/${cidr.length}`;
|
||||
} else if (data.startAddress && data.endAddress) {
|
||||
networkRange = `${data.startAddress} - ${data.endAddress}`;
|
||||
}
|
||||
|
||||
// Parse entities
|
||||
if (data.entities && Array.isArray(data.entities)) {
|
||||
for (const entity of data.entities) {
|
||||
const roles: string[] = entity.roles || [];
|
||||
|
||||
if (roles.includes('registrant') || roles.includes('administrative')) {
|
||||
const orgName = this.extractVcardFn(entity);
|
||||
if (orgName) registrantOrg = orgName;
|
||||
|
||||
// Try to get country from registrant address if not at top level
|
||||
if (!registrantCountry) {
|
||||
registrantCountry = this.extractVcardCountry(entity);
|
||||
}
|
||||
}
|
||||
|
||||
if (roles.includes('abuse')) {
|
||||
abuseContact = this.extractVcardEmail(entity);
|
||||
// Check nested entities for abuse contact
|
||||
if (!abuseContact && entity.entities) {
|
||||
for (const subEntity of entity.entities) {
|
||||
const subRoles: string[] = subEntity.roles || [];
|
||||
if (subRoles.includes('abuse')) {
|
||||
abuseContact = this.extractVcardEmail(subEntity);
|
||||
if (abuseContact) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { registrantOrg, registrantCountry, networkRange, abuseContact };
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'fn' (formatted name) from an entity's vcardArray
|
||||
*/
|
||||
private extractVcardFn(entity: any): string | null {
|
||||
if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'fn') {
|
||||
return prop[3] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract email from an entity's vcardArray
|
||||
*/
|
||||
private extractVcardEmail(entity: any): string | null {
|
||||
if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'email') {
|
||||
return prop[3] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract country from an entity's vcardArray address field
|
||||
*/
|
||||
private extractVcardCountry(entity: any): string | null {
|
||||
if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'adr') {
|
||||
// The label parameter often contains the full address with country at the end
|
||||
const label = prop[1]?.label;
|
||||
if (typeof label === 'string') {
|
||||
const lines = label.split('\n');
|
||||
const lastLine = lines[lines.length - 1]?.trim();
|
||||
if (lastLine && lastLine.length > 1) return lastLine;
|
||||
}
|
||||
// Also check the structured value (7-element array, last element is country)
|
||||
const value = prop[3];
|
||||
if (Array.isArray(value) && value.length >= 7 && value[6]) {
|
||||
return value[6];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Team Cymru DNS Subsystem ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Query Team Cymru DNS for ASN information.
|
||||
* Query format: reversed.ip.origin.asn.cymru.com TXT
|
||||
* Response: "ASN | prefix | CC | rir | date"
|
||||
*/
|
||||
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
|
||||
try {
|
||||
const reversed = ip.split('.').reverse().join('.');
|
||||
const queryName = `${reversed}.origin.asn.cymru.com`;
|
||||
|
||||
const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
||||
strategy: 'prefer-system',
|
||||
allowDohFallback: true,
|
||||
timeoutMs: this.timeout,
|
||||
});
|
||||
|
||||
const records = await dnsClient.getRecordsTxt(queryName);
|
||||
if (!records || records.length === 0) return null;
|
||||
|
||||
// Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
|
||||
const txt = records[0].value || (records[0] as any).data;
|
||||
if (!txt) return null;
|
||||
|
||||
const parts = txt.split('|').map((s: string) => s.trim());
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
const asn = parseInt(parts[0], 10);
|
||||
if (isNaN(asn)) return null;
|
||||
|
||||
return {
|
||||
asn,
|
||||
prefix: parts[1] || '',
|
||||
country: parts[2] || '',
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MaxMind GeoLite2 Subsystem ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure MMDB readers are initialized. Downloads on first call,
|
||||
* triggers background refresh if stale.
|
||||
*/
|
||||
private async ensureReaders(): Promise<void> {
|
||||
if (this.cityReader && this.asnReader) {
|
||||
// Check if refresh needed
|
||||
if (Date.now() - this.lastFetchTime > this.dbMaxAge && !this.refreshPromise) {
|
||||
this.refreshPromise = this.downloadAndInitReaders()
|
||||
.catch((err) => this.logger.debug?.(`Background MMDB refresh failed: ${err.message}`))
|
||||
.finally(() => { this.refreshPromise = null; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First time: blocking download
|
||||
if (this.refreshPromise) {
|
||||
await this.refreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshPromise = this.downloadAndInitReaders();
|
||||
await this.refreshPromise;
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download MMDB files from CDN and create Reader instances
|
||||
*/
|
||||
private async downloadAndInitReaders(): Promise<void> {
|
||||
const [cityBuffer, asnBuffer] = await Promise.all([
|
||||
this.fetchBuffer(CITY_MMDB_URL),
|
||||
this.fetchBuffer(ASN_MMDB_URL),
|
||||
]);
|
||||
|
||||
this.cityReader = new plugins.maxmind.Reader<CityResponse>(cityBuffer);
|
||||
this.asnReader = new plugins.maxmind.Reader<AsnResponse>(asnBuffer);
|
||||
this.lastFetchTime = Date.now();
|
||||
this.logger.info?.('MaxMind MMDB databases loaded into memory');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL and return the response as a Buffer
|
||||
*/
|
||||
private async fetchBuffer(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query MaxMind for geo + ASN data
|
||||
*/
|
||||
private async queryMaxMind(ip: string): Promise<{
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadius: number | null;
|
||||
timezone: string | null;
|
||||
asn: number | null;
|
||||
asnOrg: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
await this.ensureReaders();
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`Failed to initialize MaxMind readers: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.cityReader || !this.asnReader) return null;
|
||||
|
||||
let country: string | null = null;
|
||||
let countryCode: string | null = null;
|
||||
let city: string | null = null;
|
||||
let latitude: number | null = null;
|
||||
let longitude: number | null = null;
|
||||
let accuracyRadius: number | null = null;
|
||||
let timezone: string | null = null;
|
||||
let asn: number | null = null;
|
||||
let asnOrg: string | null = null;
|
||||
|
||||
// City lookup
|
||||
try {
|
||||
const cityResult = this.cityReader.get(ip);
|
||||
if (cityResult) {
|
||||
country = cityResult.country?.names?.en || null;
|
||||
countryCode = cityResult.country?.iso_code || null;
|
||||
city = cityResult.city?.names?.en || null;
|
||||
latitude = cityResult.location?.latitude ?? null;
|
||||
longitude = cityResult.location?.longitude ?? null;
|
||||
accuracyRadius = cityResult.location?.accuracy_radius ?? null;
|
||||
timezone = cityResult.location?.time_zone || null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`);
|
||||
}
|
||||
|
||||
// ASN lookup
|
||||
try {
|
||||
const asnResult = this.asnReader.get(ip);
|
||||
if (asnResult) {
|
||||
asn = asnResult.autonomous_system_number ?? null;
|
||||
asnOrg = asnResult.autonomous_system_organization || null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`MaxMind ASN lookup failed for ${ip}: ${err.message}`);
|
||||
}
|
||||
|
||||
return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg };
|
||||
}
|
||||
|
||||
// ─── Utilities ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert an IPv4 address string to a 32-bit unsigned number
|
||||
*/
|
||||
private ipToNumber(ip: string): number {
|
||||
const parts = ip.split('.');
|
||||
return (
|
||||
((parseInt(parts[0], 10) << 24) |
|
||||
(parseInt(parts[1], 10) << 16) |
|
||||
(parseInt(parts[2], 10) << 8) |
|
||||
parseInt(parts[3], 10)) >>> 0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export class PublicIp {
|
||||
this.logger.info?.(`Got IPv4 from ${service.name}: ${ip}`);
|
||||
return ip;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this.logger.debug?.(`Failed to get IPv4 from ${service.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export class PublicIp {
|
||||
this.logger.info?.(`Got IPv6 from ${service.name}: ${ip}`);
|
||||
return ip;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this.logger.debug?.(`Failed to get IPv6 from ${service.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
181
ts/smartnetwork.classes.rustbridge.ts
Normal file
181
ts/smartnetwork.classes.rustbridge.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Command map for the rustnetwork IPC binary.
|
||||
* Each key maps to { params, result } defining the typed IPC protocol.
|
||||
*/
|
||||
type TNetworkCommands = {
|
||||
healthPing: {
|
||||
params: Record<string, never>;
|
||||
result: { pong: boolean };
|
||||
};
|
||||
ping: {
|
||||
params: { host: string; count?: number; timeoutMs?: number };
|
||||
result: {
|
||||
alive: boolean;
|
||||
times: (number | null)[];
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
stddev: number | null;
|
||||
packetLoss: number;
|
||||
};
|
||||
};
|
||||
traceroute: {
|
||||
params: { host: string; maxHops?: number; timeoutMs?: number };
|
||||
result: {
|
||||
hops: Array<{ ttl: number; ip: string; rtt: number | null }>;
|
||||
};
|
||||
};
|
||||
tcpPortCheck: {
|
||||
params: { host: string; port: number; timeoutMs?: number };
|
||||
result: { isOpen: boolean; latencyMs: number | null };
|
||||
};
|
||||
isLocalPortFree: {
|
||||
params: { port: number };
|
||||
result: { free: boolean };
|
||||
};
|
||||
defaultGateway: {
|
||||
params: Record<string, never>;
|
||||
result: {
|
||||
interfaceName: string;
|
||||
addresses: Array<{ family: string; address: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function getPlatformSuffix(): string | null {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
linux: 'linux',
|
||||
darwin: 'macos',
|
||||
win32: 'windows',
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
x64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
};
|
||||
|
||||
const p = platformMap[platform];
|
||||
const a = archMap[arch];
|
||||
|
||||
if (p && a) {
|
||||
return `${p}_${a}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton bridge to the rustnetwork binary.
|
||||
* Manages the IPC lifecycle for network diagnostics operations.
|
||||
*/
|
||||
export class RustNetworkBridge {
|
||||
private static instance: RustNetworkBridge | null = null;
|
||||
|
||||
public static getInstance(): RustNetworkBridge {
|
||||
if (!RustNetworkBridge.instance) {
|
||||
RustNetworkBridge.instance = new RustNetworkBridge();
|
||||
}
|
||||
return RustNetworkBridge.instance;
|
||||
}
|
||||
|
||||
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TNetworkCommands>>;
|
||||
|
||||
private constructor() {
|
||||
const packageDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
|
||||
const platformSuffix = getPlatformSuffix();
|
||||
const localPaths: string[] = [];
|
||||
|
||||
// Platform-specific cross-compiled binary
|
||||
if (platformSuffix) {
|
||||
localPaths.push(path.join(packageDir, 'dist_rust', `rustnetwork_${platformSuffix}`));
|
||||
}
|
||||
// Native build without suffix
|
||||
localPaths.push(path.join(packageDir, 'dist_rust', 'rustnetwork'));
|
||||
// Local dev paths
|
||||
localPaths.push(path.join(packageDir, 'rust', 'target', 'release', 'rustnetwork'));
|
||||
localPaths.push(path.join(packageDir, 'rust', 'target', 'debug', 'rustnetwork'));
|
||||
|
||||
this.bridge = new plugins.smartrust.RustBridge<TNetworkCommands>({
|
||||
binaryName: 'rustnetwork',
|
||||
cliArgs: ['--management'],
|
||||
requestTimeoutMs: 30_000,
|
||||
readyTimeoutMs: 10_000,
|
||||
localPaths,
|
||||
searchSystemPath: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the Rust binary and wait for it to be ready.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
const ok = await this.bridge.spawn();
|
||||
if (!ok) {
|
||||
throw new Error('Failed to spawn rustnetwork binary');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the Rust binary.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.bridge.kill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the bridge is running before sending a command.
|
||||
*/
|
||||
private async ensureRunning(): Promise<void> {
|
||||
// The bridge will throw if not spawned — we just call start() if not yet running
|
||||
}
|
||||
|
||||
// ===== Command wrappers =====
|
||||
|
||||
public async ping(
|
||||
host: string,
|
||||
count?: number,
|
||||
timeoutMs?: number,
|
||||
): Promise<TNetworkCommands['ping']['result']> {
|
||||
return this.bridge.sendCommand('ping', { host, count, timeoutMs });
|
||||
}
|
||||
|
||||
public async traceroute(
|
||||
host: string,
|
||||
maxHops?: number,
|
||||
timeoutMs?: number,
|
||||
): Promise<TNetworkCommands['traceroute']['result']> {
|
||||
return this.bridge.sendCommand('traceroute', { host, maxHops, timeoutMs });
|
||||
}
|
||||
|
||||
public async tcpPortCheck(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs?: number,
|
||||
): Promise<TNetworkCommands['tcpPortCheck']['result']> {
|
||||
return this.bridge.sendCommand('tcpPortCheck', { host, port, timeoutMs });
|
||||
}
|
||||
|
||||
public async isLocalPortFree(
|
||||
port: number,
|
||||
): Promise<TNetworkCommands['isLocalPortFree']['result']> {
|
||||
return this.bridge.sendCommand('isLocalPortFree', { port });
|
||||
}
|
||||
|
||||
public async defaultGateway(): Promise<TNetworkCommands['defaultGateway']['result']> {
|
||||
return this.bridge.sendCommand('defaultGateway', {} as Record<string, never>);
|
||||
}
|
||||
|
||||
public async healthPing(): Promise<TNetworkCommands['healthPing']['result']> {
|
||||
return this.bridge.sendCommand('healthPing', {} as Record<string, never>);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
|
||||
import { PublicIp } from './smartnetwork.classes.publicip.js';
|
||||
import { IpIntelligence, type IIpIntelligenceResult } from './smartnetwork.classes.ipintelligence.js';
|
||||
import { getLogger } from './logging.js';
|
||||
import { NetworkError } from './errors.js';
|
||||
import * as stats from './helpers/stats.js';
|
||||
import { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
|
||||
|
||||
/**
|
||||
* SmartNetwork simplifies actions within the network
|
||||
*/
|
||||
/**
|
||||
* Configuration options for SmartNetwork
|
||||
*/
|
||||
@@ -35,6 +33,10 @@ export interface IFindFreePortOptions {
|
||||
exclude?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SmartNetwork simplifies actions within the network.
|
||||
* Uses a Rust binary for system-dependent operations (ping, traceroute, port scanning, gateway detection).
|
||||
*/
|
||||
export class SmartNetwork {
|
||||
/** Static registry for external plugins */
|
||||
public static pluginsRegistry: Map<string, any> = new Map();
|
||||
@@ -46,15 +48,51 @@ export class SmartNetwork {
|
||||
public static unregisterPlugin(name: string): void {
|
||||
SmartNetwork.pluginsRegistry.delete(name);
|
||||
}
|
||||
|
||||
private options: SmartNetworkOptions;
|
||||
private cache: Map<string, { value: any; expiry: number }>;
|
||||
private rustBridge: RustNetworkBridge;
|
||||
private bridgeStarted = false;
|
||||
private ipIntelligence: IpIntelligence | null = null;
|
||||
|
||||
constructor(options?: SmartNetworkOptions) {
|
||||
this.options = options || {};
|
||||
this.cache = new Map();
|
||||
this.rustBridge = RustNetworkBridge.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* get network speed
|
||||
* @param opts optional speed test parameters
|
||||
* Start the Rust binary bridge. Must be called before using ping, traceroute,
|
||||
* port scanning, or gateway operations. Safe to call multiple times.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (!this.bridgeStarted) {
|
||||
await this.rustBridge.start();
|
||||
this.bridgeStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Rust binary bridge.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.bridgeStarted) {
|
||||
await this.rustBridge.stop();
|
||||
this.bridgeStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Rust bridge is running before sending commands.
|
||||
*/
|
||||
private async ensureBridge(): Promise<void> {
|
||||
if (!this.bridgeStarted) {
|
||||
await this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network speed via Cloudflare speed test (pure TS, no Rust needed).
|
||||
*/
|
||||
public async getSpeed(opts?: { parallelStreams?: number; duration?: number }) {
|
||||
const cloudflareSpeedInstance = new CloudflareSpeed(opts);
|
||||
@@ -65,35 +103,45 @@ export class SmartNetwork {
|
||||
* Send ICMP pings to a host. Optionally specify count for multiple pings.
|
||||
*/
|
||||
public async ping(host: string, opts?: { timeout?: number; count?: number }): Promise<any> {
|
||||
const timeout = opts?.timeout ?? 500;
|
||||
await this.ensureBridge();
|
||||
const timeoutMs = opts?.timeout ?? 5000;
|
||||
const count = opts?.count && opts.count > 1 ? opts.count : 1;
|
||||
const pinger = new plugins.smartping.Smartping();
|
||||
if (count === 1) {
|
||||
// single ping: normalize time to number
|
||||
const res = await pinger.ping(host, timeout);
|
||||
|
||||
let result: Awaited<ReturnType<typeof this.rustBridge.ping>>;
|
||||
try {
|
||||
result = await this.rustBridge.ping(host, count, timeoutMs);
|
||||
} catch {
|
||||
// DNS resolution failure or other error — return dead ping
|
||||
if (count === 1) {
|
||||
return { alive: false, time: NaN };
|
||||
}
|
||||
return {
|
||||
...res,
|
||||
time: typeof res.time === 'number' ? res.time : NaN,
|
||||
host,
|
||||
count,
|
||||
times: Array(count).fill(NaN),
|
||||
min: NaN,
|
||||
max: NaN,
|
||||
avg: NaN,
|
||||
stddev: NaN,
|
||||
packetLoss: 100,
|
||||
alive: false,
|
||||
};
|
||||
}
|
||||
const times: number[] = [];
|
||||
let aliveCount = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const res = await pinger.ping(host, timeout);
|
||||
const t = typeof res.time === 'number' ? res.time : NaN;
|
||||
if (res.alive) aliveCount++;
|
||||
times.push(t);
|
||||
} catch {
|
||||
times.push(NaN);
|
||||
}
|
||||
|
||||
// Map times: replace null with NaN for backward compatibility
|
||||
const times = result.times.map((t) => (t === null ? NaN : t));
|
||||
const min = result.min === null ? NaN : result.min;
|
||||
const max = result.max === null ? NaN : result.max;
|
||||
const avg = result.avg === null ? NaN : result.avg;
|
||||
const stddev = result.stddev === null ? NaN : result.stddev;
|
||||
|
||||
if (count === 1) {
|
||||
return {
|
||||
alive: result.alive,
|
||||
time: times[0] ?? NaN,
|
||||
};
|
||||
}
|
||||
const valid = times.filter((t) => !isNaN(t));
|
||||
const min = valid.length ? Math.min(...valid) : NaN;
|
||||
const max = valid.length ? Math.max(...valid) : NaN;
|
||||
const avg = valid.length ? stats.average(valid) : NaN;
|
||||
const stddev = valid.length ? Math.sqrt(stats.average(valid.map((v) => (v - avg) ** 2))) : NaN;
|
||||
const packetLoss = ((count - aliveCount) / count) * 100;
|
||||
|
||||
return {
|
||||
host,
|
||||
count,
|
||||
@@ -102,65 +150,22 @@ export class SmartNetwork {
|
||||
max,
|
||||
avg,
|
||||
stddev,
|
||||
packetLoss,
|
||||
alive: aliveCount > 0,
|
||||
packetLoss: result.packetLoss,
|
||||
alive: result.alive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a promise with a boolean answer
|
||||
* note: false also resolves with false as argument
|
||||
* @param port
|
||||
*/
|
||||
/**
|
||||
* Check if a local port is unused (both IPv4 and IPv6)
|
||||
*/
|
||||
public async isLocalPortUnused(port: number): Promise<boolean> {
|
||||
const doneIpV4 = plugins.smartpromise.defer<boolean>();
|
||||
const doneIpV6 = plugins.smartpromise.defer<boolean>();
|
||||
const net = await import('net'); // creates only one instance of net ;) even on multiple calls
|
||||
|
||||
// test IPv4 space
|
||||
const ipv4Test = net.createServer();
|
||||
ipv4Test.once('error', () => {
|
||||
doneIpV4.resolve(false);
|
||||
});
|
||||
ipv4Test.once('listening', () => {
|
||||
ipv4Test.once('close', () => {
|
||||
doneIpV4.resolve(true);
|
||||
});
|
||||
ipv4Test.close();
|
||||
});
|
||||
ipv4Test.listen(port, '0.0.0.0');
|
||||
|
||||
await doneIpV4.promise;
|
||||
|
||||
// test IPv6 space
|
||||
const ipv6Test = net.createServer();
|
||||
ipv6Test.once('error', () => {
|
||||
doneIpV6.resolve(false);
|
||||
});
|
||||
ipv6Test.once('listening', () => {
|
||||
ipv6Test.once('close', () => {
|
||||
doneIpV6.resolve(true);
|
||||
});
|
||||
ipv6Test.close();
|
||||
});
|
||||
ipv6Test.listen(port, '::');
|
||||
|
||||
// lets wait for the result
|
||||
const resultIpV4 = await doneIpV4.promise;
|
||||
const resultIpV6 = await doneIpV6.promise;
|
||||
const result = resultIpV4 === true && resultIpV6 === true;
|
||||
return result;
|
||||
await this.ensureBridge();
|
||||
const result = await this.rustBridge.isLocalPortFree(port);
|
||||
return result.free;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first available port within a given range
|
||||
* @param startPort The start of the port range (inclusive)
|
||||
* @param endPort The end of the port range (inclusive)
|
||||
* @param options Optional configuration for port selection behavior
|
||||
* @returns The first available port number (or random if options.randomize is true), or null if no ports are available
|
||||
*/
|
||||
public async findFreePort(startPort: number, endPort: number, options?: IFindFreePortOptions): Promise<number | null> {
|
||||
// Validate port range
|
||||
@@ -171,66 +176,36 @@ export class SmartNetwork {
|
||||
throw new NetworkError('Start port must be less than or equal to end port', 'EINVAL');
|
||||
}
|
||||
|
||||
// Create a set of excluded ports for efficient lookup
|
||||
const excludedPorts = new Set(options?.exclude || []);
|
||||
|
||||
// If randomize option is true, collect all available ports and select randomly
|
||||
if (options?.randomize) {
|
||||
const availablePorts: number[] = [];
|
||||
|
||||
// Scan the range to find all available ports
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
// Skip excluded ports
|
||||
if (excludedPorts.has(port)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedPorts.has(port)) continue;
|
||||
const isUnused = await this.isLocalPortUnused(port);
|
||||
if (isUnused) {
|
||||
availablePorts.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are available ports, select one randomly
|
||||
if (availablePorts.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * availablePorts.length);
|
||||
return availablePorts[randomIndex];
|
||||
}
|
||||
|
||||
// No free port found in the range
|
||||
return null;
|
||||
} else {
|
||||
// Default behavior: return the first available port (sequential search)
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
// Skip excluded ports
|
||||
if (excludedPorts.has(port)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedPorts.has(port)) continue;
|
||||
const isUnused = await this.isLocalPortUnused(port);
|
||||
if (isUnused) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
// No free port found in the range
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* checks wether a remote port is available
|
||||
* @param domainArg
|
||||
*/
|
||||
/**
|
||||
* Check if a remote port is available
|
||||
* @param target host or "host:port"
|
||||
* @param opts options including port, protocol (only tcp), retries and timeout
|
||||
*/
|
||||
/**
|
||||
* Check if a remote port is available
|
||||
* @param target host or "host:port"
|
||||
* @param portOrOpts either a port number (deprecated) or options object
|
||||
*/
|
||||
public async isRemotePortAvailable(
|
||||
target: string,
|
||||
@@ -243,7 +218,7 @@ export class SmartNetwork {
|
||||
let protocol: string = 'tcp';
|
||||
let retries = 1;
|
||||
let timeout: number | undefined;
|
||||
// preserve old signature (target, port)
|
||||
|
||||
if (typeof portOrOpts === 'number') {
|
||||
[hostPart] = target.split(':');
|
||||
port = portOrOpts;
|
||||
@@ -256,47 +231,64 @@ export class SmartNetwork {
|
||||
const portPart = target.split(':')[1];
|
||||
port = opts.port ?? (portPart ? parseInt(portPart, 10) : undefined);
|
||||
}
|
||||
|
||||
if (protocol === 'udp') {
|
||||
throw new NetworkError('UDP port check not supported', 'ENOTSUP');
|
||||
}
|
||||
if (!port) {
|
||||
throw new NetworkError('Port not specified', 'EINVAL');
|
||||
}
|
||||
let last: boolean = false;
|
||||
|
||||
await this.ensureBridge();
|
||||
let last = false;
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
const done = plugins.smartpromise.defer<boolean>();
|
||||
plugins.isopen(hostPart, port, (response: Record<string, { isOpen: boolean }>) => {
|
||||
const info = response[port.toString()];
|
||||
done.resolve(Boolean(info?.isOpen));
|
||||
});
|
||||
last = await done.promise;
|
||||
if (last) return true;
|
||||
try {
|
||||
const result = await this.rustBridge.tcpPortCheck(hostPart, port, timeout);
|
||||
last = result.isOpen;
|
||||
if (last) return true;
|
||||
} catch {
|
||||
// DNS resolution failure or connection error — treat as not available
|
||||
last = false;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* List network interfaces (gateways)
|
||||
* List network interfaces (gateways) — pure TS, no Rust needed.
|
||||
*/
|
||||
public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
|
||||
const fetcher = async () => plugins.os.networkInterfaces();
|
||||
const fetcher = async () => plugins.os.networkInterfaces() as Record<string, plugins.os.NetworkInterfaceInfo[]>;
|
||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||
return this.getCached('gateways', fetcher);
|
||||
}
|
||||
return fetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default gateway interface and its addresses.
|
||||
*/
|
||||
public async getDefaultGateway(): Promise<{
|
||||
ipv4: plugins.os.NetworkInterfaceInfo;
|
||||
ipv6: plugins.os.NetworkInterfaceInfo;
|
||||
}> {
|
||||
const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault();
|
||||
if (!defaultGatewayName) {
|
||||
} | null> {
|
||||
await this.ensureBridge();
|
||||
const result = await this.rustBridge.defaultGateway();
|
||||
const interfaceName = result.interfaceName;
|
||||
|
||||
if (!interfaceName) {
|
||||
getLogger().warn?.('Cannot determine default gateway');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use os.networkInterfaces() to get rich interface info
|
||||
const gateways = await this.getGateways();
|
||||
const defaultGateway = gateways[defaultGatewayName];
|
||||
const defaultGateway = gateways[interfaceName];
|
||||
if (!defaultGateway) {
|
||||
getLogger().warn?.(`Interface ${interfaceName} not found in os.networkInterfaces()`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ipv4: defaultGateway[0],
|
||||
ipv6: defaultGateway[1],
|
||||
@@ -304,7 +296,7 @@ export class SmartNetwork {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup public IPv4 and IPv6
|
||||
* Lookup public IPv4 and IPv6 — pure TS, no Rust needed.
|
||||
*/
|
||||
public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
|
||||
const fetcher = async () => {
|
||||
@@ -318,28 +310,25 @@ export class SmartNetwork {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DNS records (A, AAAA, MX)
|
||||
* Resolve DNS records (A, AAAA, MX) — uses smartdns, no Rust needed.
|
||||
*/
|
||||
public async resolveDns(
|
||||
host: string,
|
||||
): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
|
||||
try {
|
||||
const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
||||
strategy: 'prefer-system', // Try system resolver first (handles localhost), fallback to DoH
|
||||
strategy: 'prefer-system',
|
||||
allowDohFallback: true,
|
||||
});
|
||||
|
||||
|
||||
const [aRecords, aaaaRecords, mxRecords] = await Promise.all([
|
||||
dnsClient.getRecordsA(host).catch((): any[] => []),
|
||||
dnsClient.getRecordsAAAA(host).catch((): any[] => []),
|
||||
dnsClient.getRecords(host, 'MX').catch((): any[] => []),
|
||||
]);
|
||||
|
||||
// Extract values from the record objects
|
||||
|
||||
const A = aRecords.map((record: any) => record.value);
|
||||
const AAAA = aaaaRecords.map((record: any) => record.value);
|
||||
|
||||
// Parse MX records - the value contains "priority exchange"
|
||||
const MX = mxRecords.map((record: any) => {
|
||||
const parts = record.value.split(' ');
|
||||
return {
|
||||
@@ -347,7 +336,7 @@ export class SmartNetwork {
|
||||
exchange: parts[1] || '',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
return { A, AAAA, MX };
|
||||
} catch (err: any) {
|
||||
throw new NetworkError(err.message, err.code);
|
||||
@@ -355,20 +344,20 @@ export class SmartNetwork {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a simple HTTP/HTTPS endpoint health check
|
||||
* Perform a simple HTTP/HTTPS endpoint health check — pure TS.
|
||||
*/
|
||||
public async checkEndpoint(
|
||||
urlString: string,
|
||||
opts?: { timeout?: number },
|
||||
opts?: { timeout?: number; rejectUnauthorized?: boolean },
|
||||
): Promise<{ status: number; headers: Record<string, string>; rtt: number }> {
|
||||
const start = plugins.perfHooks.performance.now();
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const lib = url.protocol === 'https:' ? plugins.https : await import('http');
|
||||
const lib = url.protocol === 'https:' ? plugins.https : await import('node:http');
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = lib.request(
|
||||
url,
|
||||
{ method: 'GET', timeout: opts?.timeout, agent: false },
|
||||
{ method: 'GET', timeout: opts?.timeout, agent: false, rejectUnauthorized: opts?.rejectUnauthorized ?? true },
|
||||
(res: any) => {
|
||||
res.on('data', () => {});
|
||||
res.once('end', () => {
|
||||
@@ -390,50 +379,38 @@ export class SmartNetwork {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a traceroute: hop-by-hop latency using the system traceroute tool.
|
||||
* Falls back to a single-hop stub if traceroute is unavailable or errors.
|
||||
* Perform a traceroute: hop-by-hop latency using the Rust binary.
|
||||
*/
|
||||
public async traceroute(
|
||||
host: string,
|
||||
opts?: { maxHops?: number; timeout?: number },
|
||||
): Promise<Hop[]> {
|
||||
await this.ensureBridge();
|
||||
const maxHops = opts?.maxHops ?? 30;
|
||||
const timeout = opts?.timeout;
|
||||
try {
|
||||
const { exec } = await import('child_process');
|
||||
const cmd = `traceroute -n -m ${maxHops} ${host}`;
|
||||
const stdout: string = await new Promise((resolve, reject) => {
|
||||
exec(cmd, { encoding: 'utf8', timeout }, (err, stdout) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
const hops: Hop[] = [];
|
||||
for (const raw of stdout.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('traceroute')) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
const ttl = parseInt(parts[0], 10);
|
||||
let ip: string;
|
||||
let rtt: number | null;
|
||||
if (parts[1] === '*' || !parts[1]) {
|
||||
ip = parts[1] || '';
|
||||
rtt = null;
|
||||
} else {
|
||||
ip = parts[1];
|
||||
const timePart = parts.find((p, i) => i >= 2 && /^\d+(\.\d+)?$/.test(p));
|
||||
rtt = timePart ? parseFloat(timePart) : null;
|
||||
}
|
||||
hops.push({ ttl, ip, rtt });
|
||||
}
|
||||
if (hops.length) {
|
||||
return hops;
|
||||
}
|
||||
} catch {
|
||||
// traceroute not available or error: fall through to stub
|
||||
const timeoutMs = opts?.timeout ?? 5000;
|
||||
|
||||
const result = await this.rustBridge.traceroute(host, maxHops, timeoutMs);
|
||||
return result.hops.map((h) => ({
|
||||
ttl: h.ttl,
|
||||
ip: h.ip || '*',
|
||||
rtt: h.rtt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IP intelligence: ASN, organization, geolocation, and RDAP registration data.
|
||||
* Combines RDAP (RIRs), Team Cymru DNS, and MaxMind GeoLite2 — all run in parallel.
|
||||
* Pure TS, no Rust needed.
|
||||
*/
|
||||
public async getIpIntelligence(ip: string): Promise<IIpIntelligenceResult> {
|
||||
if (!this.ipIntelligence) {
|
||||
this.ipIntelligence = new IpIntelligence();
|
||||
}
|
||||
// fallback stub
|
||||
return [{ ttl: 1, ip: host, rtt: null }];
|
||||
const fetcher = () => this.ipIntelligence!.getIntelligence(ip);
|
||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||
return this.getCached(`ipIntelligence:${ip}`, fetcher);
|
||||
}
|
||||
return fetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
// native scope
|
||||
import * as os from 'os';
|
||||
import * as https from 'https';
|
||||
import * as perfHooks from 'perf_hooks';
|
||||
import * as os from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
import * as perfHooks from 'node:perf_hooks';
|
||||
|
||||
export { os, https, perfHooks };
|
||||
|
||||
// @pushrocks scope
|
||||
import * as smartdns from '@push.rocks/smartdns';
|
||||
import * as smartping from '@push.rocks/smartping';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartstring from '@push.rocks/smartstring';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
|
||||
export { smartdns, smartpromise, smartping, smartstring };
|
||||
export { smartdns, smartrust };
|
||||
|
||||
// @third party scope
|
||||
// @ts-ignore
|
||||
import isopen from 'isopen';
|
||||
import * as systeminformation from 'systeminformation';
|
||||
// third party
|
||||
import * as maxmind from 'maxmind';
|
||||
|
||||
export { isopen, systeminformation };
|
||||
export { maxmind };
|
||||
|
||||
Reference in New Issue
Block a user