mirror of
https://dev.azure.com/tstanciu94/PhantomMind/_git/Bitip
synced 2025-10-13 01:52:19 +03:00
feat: Implement BitipClient for GeoIP service integration - Add BitipClient class for interacting with the Bitip GeoIP Service. - Implement methods for health check, version info, IP location lookup, detailed IP location, and batch IP lookup. - Introduce validation for IP addresses with IpValidator utility. - Normalize URLs with UrlNormalizer utility. - Create constants for API keys and routes. - Add TypeScript types for client options, responses, and errors. - Set up ESLint and Prettier configurations for code quality. - Add unit tests for BitipClient and IpValidator. - Configure TypeScript and build settings with tsup. - Set up Vitest for testing framework and coverage reporting.
193 lines
5.5 KiB
TypeScript
193 lines
5.5 KiB
TypeScript
import { Reader, ReaderModel, City, Asn } from '@maxmind/geoip2-node';
|
|
import { isIP } from 'net';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import NodeCache from 'node-cache';
|
|
import {
|
|
GeoIPLocation,
|
|
SimplifiedGeoIPResponse,
|
|
DetailedGeoIPResponse,
|
|
BatchFailedLookup,
|
|
BatchGeoIPResponse,
|
|
} from '../types/index';
|
|
import config from './config';
|
|
import logger from './logger';
|
|
|
|
class GeoIPService {
|
|
private cityReader?: ReaderModel;
|
|
private asnReader?: ReaderModel;
|
|
private cache: NodeCache;
|
|
private dbPath: string;
|
|
private isInitialized: boolean = false;
|
|
|
|
constructor() {
|
|
this.cache = new NodeCache({ stdTTL: 300, checkperiod: 60 }); // 5 minutes cache
|
|
this.dbPath = config.maxmindDbPath;
|
|
this.initializeReader();
|
|
}
|
|
|
|
private async initializeReader(): Promise<void> {
|
|
try {
|
|
const cityDbPath = path.join(this.dbPath, 'GeoLite2-City.mmdb');
|
|
const asnDbPath = path.join(this.dbPath, 'GeoLite2-ASN.mmdb');
|
|
|
|
if (!fs.existsSync(cityDbPath)) {
|
|
throw new Error(`GeoIP City database not found at ${cityDbPath}`);
|
|
}
|
|
|
|
if (!fs.existsSync(asnDbPath)) {
|
|
throw new Error(`GeoIP ASN database not found at ${asnDbPath}`);
|
|
}
|
|
|
|
this.cityReader = await Reader.open(cityDbPath);
|
|
this.asnReader = await Reader.open(asnDbPath);
|
|
this.isInitialized = true;
|
|
|
|
logger.info('GeoIP databases initialized successfully', {
|
|
cityDbPath,
|
|
asnDbPath,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to initialize GeoIP database', error as Error, {
|
|
dbPath: this.dbPath,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private ensureInitialized(): void {
|
|
if (!this.isInitialized || !this.cityReader || !this.asnReader) {
|
|
throw new Error('GeoIP service not initialized');
|
|
}
|
|
}
|
|
|
|
async lookupSimple(ip: string): Promise<SimplifiedGeoIPResponse> {
|
|
this.ensureInitialized();
|
|
|
|
const cacheKey = `simple_${ip}`;
|
|
const cached = this.cache.get<SimplifiedGeoIPResponse>(cacheKey);
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const cityResponse: City = this.cityReader!.city(ip);
|
|
const asnResponse: Asn = this.asnReader!.asn(ip);
|
|
|
|
const result: SimplifiedGeoIPResponse = {
|
|
ip,
|
|
country: cityResponse.country?.names?.en || 'Unknown',
|
|
country_code: cityResponse.country?.isoCode || 'Unknown',
|
|
is_in_european_union: cityResponse.country?.isInEuropeanUnion || false,
|
|
region: cityResponse.subdivisions?.[0]?.names?.en || 'Unknown',
|
|
region_code: cityResponse.subdivisions?.[0]?.isoCode || null,
|
|
city: cityResponse.city?.names?.en || 'Unknown',
|
|
latitude: cityResponse.location?.latitude || null,
|
|
longitude: cityResponse.location?.longitude || null,
|
|
timezone: cityResponse.location?.timeZone || null,
|
|
postal_code: cityResponse.postal?.code || null,
|
|
continent_code: cityResponse.continent?.code || null,
|
|
continent_name: cityResponse.continent?.names?.en || null,
|
|
organization: asnResponse.autonomousSystemOrganization || null,
|
|
asn: asnResponse.autonomousSystemNumber || null,
|
|
};
|
|
|
|
this.cache.set(cacheKey, result);
|
|
logger.debug('GeoIP lookup completed', { ip, result });
|
|
return result;
|
|
} catch (error) {
|
|
logger.error('GeoIP lookup failed', error as Error, { ip });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async lookupDetailed(ip: string): Promise<DetailedGeoIPResponse> {
|
|
this.ensureInitialized();
|
|
|
|
const cacheKey = `detailed_${ip}`;
|
|
const cached = this.cache.get<DetailedGeoIPResponse>(cacheKey);
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const response: City = this.cityReader!.city(ip);
|
|
const asnResponse: Asn = this.asnReader!.asn(ip);
|
|
|
|
const result: DetailedGeoIPResponse = {
|
|
ip,
|
|
location: response as GeoIPLocation,
|
|
asn: asnResponse,
|
|
};
|
|
|
|
this.cache.set(cacheKey, result);
|
|
logger.debug('Detailed GeoIP lookup completed', { ip });
|
|
return result;
|
|
} catch (error) {
|
|
logger.error('Detailed GeoIP lookup failed', error as Error, { ip });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async lookupBatch(ips: string[]): Promise<BatchGeoIPResponse> {
|
|
const results = await Promise.allSettled(
|
|
ips.map(async ip => this.lookupSimple(ip))
|
|
);
|
|
|
|
const succeeded: SimplifiedGeoIPResponse[] = [];
|
|
const failed: Array<BatchFailedLookup> = [];
|
|
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
succeeded.push(result.value);
|
|
} else {
|
|
failed.push({
|
|
ip: ips[index] || 'unknown',
|
|
error: result.reason?.message || 'Lookup failed',
|
|
});
|
|
}
|
|
});
|
|
|
|
return { succeeded, failed };
|
|
}
|
|
|
|
/**
|
|
* Validates if a string is a valid IP address (IPv4 or IPv6)
|
|
* @param ip - IP address string to validate
|
|
* @returns true if valid IP address, false otherwise
|
|
*/
|
|
isValidIP(ip: string): boolean {
|
|
return isIP(ip) !== 0;
|
|
}
|
|
|
|
isPrivateIP(ip: string): boolean {
|
|
const privateRanges = [
|
|
/^10\./,
|
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
/^192\.168\./,
|
|
/^127\./,
|
|
/^169\.254\./,
|
|
/^::1$/,
|
|
/^fc00:/,
|
|
/^fe80:/,
|
|
];
|
|
|
|
return privateRanges.some(range => range.test(ip));
|
|
}
|
|
|
|
async healthCheck(): Promise<boolean> {
|
|
try {
|
|
await this.lookupSimple('8.8.8.8'); // Test with Google DNS
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('GeoIP service health check failed', error as Error);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const geoIPService = new GeoIPService();
|
|
export default geoIPService;
|