Tudor Stanciu dbb821fe92 Merged PR 110: feat: Implement Node BitipClient for GeoIP service integration
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.
2025-10-12 11:54:44 +00:00

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;