UNPKG

geoip-lite2

Version:

Fast, native implementation of the GeoIP API from MaxMind in JavaScript. An improved version by Sefinek, continuously maintained.

385 lines (330 loc) 11.2 kB
const { openSync, fstatSync, readSync, closeSync } = require('node:fs'); const fsPromises = require('node:fs/promises'); const { basename, join, resolve } = require('node:path'); const { isIP } = require('node:net'); const { aton4, aton6, cmp6, removeNullTerminator, readIp6, createGeoData, populateGeoDataFromLocation } = require('./scripts/utils.js'); const fsWatcher = require('./scripts/fsWatcher.js'); const { version } = require('./package.json'); const WATCHER_NAME = 'dataWatcher'; const GEO_DATA_DIR = resolve(__dirname, globalThis['geoDataDir'] || process.env.GEOIP_DATA_DIR || './data/'); const DATA_FILES = { city: join(GEO_DATA_DIR, 'geoip-city.dat'), city6: join(GEO_DATA_DIR, 'geoip-city6.dat'), cityNames: join(GEO_DATA_DIR, 'geoip-city-names.dat'), country: join(GEO_DATA_DIR, 'geoip-country.dat'), country6: join(GEO_DATA_DIR, 'geoip-country6.dat'), }; const WATCHED_DATA_FILES = Object.values(DATA_FILES).map(filePath => basename(filePath)); const PRIVATE_RANGE4 = [ [aton4('10.0.0.0'), aton4('10.255.255.255')], [aton4('172.16.0.0'), aton4('172.31.255.255')], [aton4('192.168.0.0'), aton4('192.168.255.255')], ]; const RECORD_SIZE = 10; const RECORD_SIZE6 = 34; const CONF4 = { firstIP: null, lastIP: null, lastRecordIdx: 0, locationBuffer: null, locationRecordSize: 88, mainBuffer: null, recordSize: 24, }; const CONF6 = { firstIP: null, lastIP: null, lastRecordIdx: 0, mainBuffer: null, recordSize: 48, }; let cache4 = { ...CONF4 }; let cache6 = { ...CONF6 }; const reportReloadError = err => err ? console.error('[geoip-lite2] Failed to reload GeoIP data:', err) : null; const lookup4 = ip => { if (!cache4.mainBuffer) return null; let fline = 0; let cline = cache4.lastRecordIdx; let floor; let ceil; let line, locId; const buffer = cache4.mainBuffer; const locBuffer = cache4.locationBuffer; const privateRange = PRIVATE_RANGE4; const recordSize = cache4.recordSize; const locRecordSize = cache4.locationRecordSize; if (ip > cache4.lastIP || ip < cache4.firstIP) return null; for (let i = 0; i < privateRange.length; i++) { if (ip >= privateRange[i][0] && ip <= privateRange[i][1]) return null; } const geoData = createGeoData(); while (true) { line = Math.round((cline - fline) / 2) + fline; const offset = line * recordSize; floor = buffer.readUInt32BE(offset); ceil = buffer.readUInt32BE(offset + 4); if (floor <= ip && ceil >= ip) { if (recordSize === RECORD_SIZE) { geoData.country = removeNullTerminator(buffer.toString('utf8', offset + 8, offset + 10)); } else { locId = buffer.readUInt32BE(offset + 8); populateGeoDataFromLocation({ geoData, locationBuffer: locBuffer, locationRecordSize: locRecordSize, locationId: locId, coordBuffer: buffer, latitudeOffset: offset + 12, longitudeOffset: offset + 16, areaOffset: offset + 20, }); } return geoData; } else if (fline === cline) { return null; } else if (fline === (cline - 1)) { if (line === fline) { fline = cline; } else { cline = fline; } } else if (floor > ip) { cline = line; } else if (ceil < ip) { fline = line; } } }; const lookup6 = ip => { if (!cache6.mainBuffer) return null; const buffer = cache6.mainBuffer; const recordSize = cache6.recordSize; const locBuffer = cache4.locationBuffer; const locRecordSize = cache4.locationRecordSize; let fline = 0; let cline = cache6.lastRecordIdx; let floor; let ceil; let line, locId; if (cmp6(ip, cache6.lastIP) > 0 || cmp6(ip, cache6.firstIP) < 0) return null; const geoData = createGeoData(); while (true) { line = Math.round((cline - fline) / 2) + fline; floor = readIp6(buffer, line, recordSize, 0); ceil = readIp6(buffer, line, recordSize, 1); if (cmp6(floor, ip) <= 0 && cmp6(ceil, ip) >= 0) { const offset = line * recordSize; if (recordSize === RECORD_SIZE6) { geoData.country = removeNullTerminator(buffer.toString('utf8', offset + 32, offset + 34)); } else { locId = buffer.readUInt32BE(offset + 32); populateGeoDataFromLocation({ geoData, locationBuffer: locBuffer, locationRecordSize: locRecordSize, locationId: locId, coordBuffer: buffer, latitudeOffset: offset + 36, longitudeOffset: offset + 40, areaOffset: offset + 44, }); } return geoData; } else if (fline === cline) { return null; } else if (fline === (cline - 1)) { if (line === fline) { fline = cline; } else { cline = fline; } } else if (cmp6(floor, ip) > 0) { cline = line; } else if (cmp6(ceil, ip) < 0) { fline = line; } } }; const V6_PREFIX_1 = '0:0:0:0:0:FFFF:'; const V6_PREFIX_2 = '::FFFF:'; const get4mapped = ip => { const ipv6 = ip.toUpperCase(); if (ipv6.startsWith(V6_PREFIX_1)) return ipv6.substring(V6_PREFIX_1.length); if (ipv6.startsWith(V6_PREFIX_2)) return ipv6.substring(V6_PREFIX_2.length); return null; }; const readFileBuffer = async filePath => { const buffer = await fsPromises.readFile(filePath); return { buffer, size: buffer.length }; }; const isExpectedMissingDataError = err => err?.code === 'ENOENT' || err?.code === 'EBADF'; const preloadAsync = async () => { const asyncCache = { ...CONF4 }; let mainData; try { const cityNamesData = await readFileBuffer(DATA_FILES.cityNames); if (cityNamesData.size === 0) { const emptyFileError = new Error('geoip-city-names.dat is empty'); emptyFileError.code = 'ENOENT'; throw emptyFileError; } asyncCache.locationBuffer = cityNamesData.buffer; mainData = await readFileBuffer(DATA_FILES.city); } catch (err) { if (!isExpectedMissingDataError(err)) throw err; asyncCache.locationBuffer = null; mainData = await readFileBuffer(DATA_FILES.country); asyncCache.recordSize = RECORD_SIZE; } asyncCache.mainBuffer = mainData.buffer; asyncCache.lastRecordIdx = (mainData.size / asyncCache.recordSize) - 1; asyncCache.lastIP = asyncCache.mainBuffer.readUInt32BE((asyncCache.lastRecordIdx * asyncCache.recordSize) + 4); asyncCache.firstIP = asyncCache.mainBuffer.readUInt32BE(0); cache4 = asyncCache; }; const preload = callback => { if (typeof callback === 'function') return preloadAsync().then(() => callback()).catch(callback); let datFile; let datSize; try { datFile = openSync(DATA_FILES.cityNames, 'r'); datSize = fstatSync(datFile).size; if (datSize === 0) { closeSync(datFile); datFile = openSync(DATA_FILES.country, 'r'); datSize = fstatSync(datFile).size; cache4.recordSize = RECORD_SIZE; } else { cache4.locationBuffer = Buffer.alloc(datSize); readSync(datFile, cache4.locationBuffer, 0, datSize, 0); closeSync(datFile); datFile = openSync(DATA_FILES.city, 'r'); datSize = fstatSync(datFile).size; } } catch (err) { if (err.code !== 'ENOENT' && err.code !== 'EBADF') throw err; cache4.locationBuffer = null; datFile = openSync(DATA_FILES.country, 'r'); datSize = fstatSync(datFile).size; cache4.recordSize = RECORD_SIZE; } cache4.mainBuffer = Buffer.alloc(datSize); readSync(datFile, cache4.mainBuffer, 0, datSize, 0); closeSync(datFile); cache4.lastRecordIdx = (datSize / cache4.recordSize) - 1; cache4.lastIP = cache4.mainBuffer.readUInt32BE((cache4.lastRecordIdx * cache4.recordSize) + 4); cache4.firstIP = cache4.mainBuffer.readUInt32BE(0); }; const preload6Async = async () => { const asyncCache6 = { ...CONF6 }; let mainData; try { const cityData = await readFileBuffer(DATA_FILES.city6); if (cityData.size === 0) { const emptyFileError = new Error('geoip-city6.dat is empty'); emptyFileError.code = 'ENOENT'; throw emptyFileError; } mainData = cityData; } catch (err) { if (!isExpectedMissingDataError(err)) throw err; mainData = await readFileBuffer(DATA_FILES.country6); asyncCache6.recordSize = RECORD_SIZE6; } asyncCache6.mainBuffer = mainData.buffer; asyncCache6.lastRecordIdx = (mainData.size / asyncCache6.recordSize) - 1; asyncCache6.lastIP = readIp6(asyncCache6.mainBuffer, asyncCache6.lastRecordIdx, asyncCache6.recordSize, 1); asyncCache6.firstIP = readIp6(asyncCache6.mainBuffer, 0, asyncCache6.recordSize, 0); cache6 = asyncCache6; }; const preload6 = callback => { if (typeof callback === 'function') return preload6Async().then(() => callback()).catch(callback); let datFile, datSize; try { datFile = openSync(DATA_FILES.city6, 'r'); datSize = fstatSync(datFile).size; if (datSize === 0) { closeSync(datFile); datFile = openSync(DATA_FILES.country6, 'r'); datSize = fstatSync(datFile).size; cache6.recordSize = RECORD_SIZE6; } } catch (err) { if (err.code !== 'ENOENT' && err.code !== 'EBADF') { throw err; } datFile = openSync(DATA_FILES.country6, 'r'); datSize = fstatSync(datFile).size; cache6.recordSize = RECORD_SIZE6; } cache6.mainBuffer = Buffer.alloc(datSize); readSync(datFile, cache6.mainBuffer, 0, datSize, 0); closeSync(datFile); cache6.lastRecordIdx = (datSize / cache6.recordSize) - 1; cache6.lastIP = readIp6(cache6.mainBuffer, cache6.lastRecordIdx, cache6.recordSize, 1); cache6.firstIP = readIp6(cache6.mainBuffer, 0, cache6.recordSize, 0); }; const runAsyncReload = callback => { Promise.all([preloadAsync(), preload6Async()]) .then(() => callback()) .catch(callback); }; module.exports = { lookup: ip => { if (ip === undefined || ip === null) throw new TypeError('lookup(ip) requires an IP address'); if (typeof ip === 'number') { if (!Number.isFinite(ip) || !Number.isInteger(ip) || ip < 0) return null; return lookup4(ip); } const ipVersion = isIP(ip); if (ipVersion === 4) { return lookup4(aton4(ip)); } else if (ipVersion === 6) { const ipv4 = get4mapped(ip); if (ipv4) { return lookup4(aton4(ipv4)); } else { return lookup6(aton6(ip)); } } return null; }, startWatchingDataUpdate: callback => { fsWatcher.makeFsWatchFilter(WATCHER_NAME, GEO_DATA_DIR, WATCHED_DATA_FILES, 60 * 1000, change => { if (change?.file) { console.log(`[geoip-lite2] Detected change in "${change.file}", reloading data...`); } else { console.log('[geoip-lite2] Detected change in GeoIP data directory, reloading data...'); } if (typeof callback === 'function') { runAsyncReload(callback); } else { runAsyncReload(reportReloadError); } }); }, stopWatchingDataUpdate: () => fsWatcher.stopWatching(WATCHER_NAME), clear: () => { cache4 = { ...CONF4 }; cache6 = { ...CONF6 }; }, reloadDataSync: () => { void preload(); void preload6(); }, reloadData: callback => { if (typeof callback === 'function') { runAsyncReload(callback); return; } return new Promise((resolve, reject) => { runAsyncReload(err => { if (err) reject(err); else resolve(); }); }); }, version, }; void preload(); void preload6();