UNPKG

geoip-lite2

Version:

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

785 lines (660 loc) 24.1 kB
const { name, version } = require('../package.json'); const UserAgent = `Mozilla/5.0 (compatible; ${name}/${version}; +https://github.com/sefinek/geoip-lite2)`; const fs = require('node:fs'); const http = require('node:http'); const https = require('node:https'); const path = require('node:path'); const zlib = require('node:zlib'); const readline = require('node:readline'); const AdmZip = require('adm-zip'); const { ipv4RangeFromCidr, ipv6RangeFromCidr } = require('../scripts/utils.js'); const log = { info: (msg, ...logArgs) => console.log(msg, ...logArgs), success: (msg, ...logArgs) => console.log(msg, ...logArgs), warn: (msg, ...logArgs) => console.warn(msg, ...logArgs), error: (msg, ...logArgs) => console.error(msg, ...logArgs), }; const TOTAL_PIPELINE_STEPS = 5; const formatNumber = value => Number(value).toLocaleString('en-US'); const formatDuration = milliseconds => { const totalSeconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes === 0) return `${seconds}s`; return `${minutes}m ${seconds}s`; }; const getDatabaseLabel = database => database.type.toUpperCase(); const getPipelinePrefix = (part, totalParts) => `[${part}/${totalParts}]`; const getCurrentPipelinePrefix = database => getPipelinePrefix(database._part, database._totalParts); const getStepPrefix = step => `[${step}/${TOTAL_PIPELINE_STEPS}]`; const logPipelineInfo = (database, message) => log.info(`${getCurrentPipelinePrefix(database)} ${getDatabaseLabel(database)}: ${message}`); const logStepInfo = (database, step, message) => log.info(`${getStepPrefix(step)} ${getDatabaseLabel(database)}: ${message}`); const logStepWarn = (database, step, message) => log.warn(`${getStepPrefix(step)} ${getDatabaseLabel(database)}: ${message}`); const logStepError = (database, step, message) => log.error(`${getStepPrefix(step)} ${getDatabaseLabel(database)}: ${message}`); const logGlobalInfo = message => log.info(`[0/0] ${message}`); const logGlobalError = message => log.error(`[0/0] ${message}`); const toLogPreview = line => (line.length > 120 ? `${line.slice(0, 117)}...` : line); const createProgressLogger = (database, step, activity) => { const startedAt = Date.now(); let lastLogAt = startedAt; const prefix = getStepPrefix(step); const dbLabel = getDatabaseLabel(database); return { maybeLog: (processedRows, writtenRows) => { const now = Date.now(); if ((now - lastLogAt) < 10000) return; lastLogAt = now; const elapsedSeconds = Math.max((now - startedAt) / 1000, 1); const avgRowsPerSecond = Math.round(processedRows / elapsedSeconds); log.info( `${prefix} ${dbLabel}: ${activity}... processed=${formatNumber(processedRows)} written=${formatNumber(writtenRows)} avg=${formatNumber(avgRowsPerSecond)}/s` ); }, done: (processedRows, writtenRows) => { const durationMs = Date.now() - startedAt; const elapsedSeconds = Math.max(durationMs / 1000, 1); const avgRowsPerSecond = Math.round(processedRows / elapsedSeconds); log.info( `${prefix} ${dbLabel}: Done! processed=${formatNumber(processedRows)} written=${formatNumber(writtenRows)} avg=${formatNumber(avgRowsPerSecond)}/s duration=${formatDuration(durationMs)}` ); }, }; }; const args = process.argv.slice(2); let license_key = args.find(arg => arg.match(/^license_key=[a-zA-Z0-9]+/) !== null); if (typeof license_key === 'undefined' && typeof process.env.MAXMIND_KEY !== 'undefined') { license_key = `license_key=${process.env.MAXMIND_KEY}`; } let geoDataDir = args.find(arg => arg.match(/^geoDataDir=[\w./]+/) !== null); if (typeof geoDataDir === 'undefined' && typeof process.env.GEOIP_DATA_DIR !== 'undefined') { geoDataDir = `geoDataDir=${process.env.GEOIP_DATA_DIR}`; } let dataPath = path.resolve(__dirname, '..', 'data'); if (typeof geoDataDir !== 'undefined') { dataPath = path.resolve(process.cwd(), geoDataDir.split('=')[1]); if (!fs.existsSync(dataPath)) { logGlobalError(`Directory does not exist: ${dataPath}`); process.exit(1); } } const tmpPath = process.env.GEOIP_TMP_DIR || path.resolve(__dirname, '..', 'tmp'); const countryLookup = {}; const cityLookup = { NaN: -1 }; const databases = [{ type: 'country', url: `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country-CSV&suffix=zip&${license_key}`, checksum: `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country-CSV&suffix=zip.sha256&${license_key}`, fileName: 'GeoLite2-Country-CSV.zip', src: [ 'GeoLite2-Country-Locations-en.csv', 'GeoLite2-Country-Blocks-IPv4.csv', 'GeoLite2-Country-Blocks-IPv6.csv', ], dest: ['', 'geoip-country.dat', 'geoip-country6.dat'], }, { type: 'city', url: `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City-CSV&suffix=zip&${license_key}`, checksum: `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City-CSV&suffix=zip.sha256&${license_key}`, fileName: 'GeoLite2-City-CSV.zip', src: [ 'GeoLite2-City-Locations-en.csv', 'GeoLite2-City-Blocks-IPv4.csv', 'GeoLite2-City-Blocks-IPv6.csv', ], dest: ['geoip-city-names.dat', 'geoip-city.dat', 'geoip-city6.dat'], }]; const ensureParentDir = dirName => { const dir = path.dirname(dirName); fs.mkdirSync(dir, { recursive: true }); }; const removePathSync = targetPath => { fs.rmSync(targetPath, { recursive: true, force: true }); }; const tryFixingLine = line => { let pos1 = 0; let pos2 = -1; line = line.replace(/""/g, '\\"').replace(/'/g, '\\\''); while (pos1 < line.length && pos2 < line.length) { pos1 = pos2; pos2 = line.indexOf(',', pos1 + 1); if (pos2 < 0) pos2 = line.length; if (line.indexOf('\'', (pos1 || 0)) > -1 && line.indexOf('\'', pos1) < pos2 && line[pos1 + 1] !== '"' && line[pos2 - 1] !== '"') { line = line.substring(0, pos1 + 1) + '"' + line.substring(pos1 + 1, pos2) + '"' + line.substring(pos2); pos2 = line.indexOf(',', pos2 + 1); if (pos2 < 0) pos2 = line.length; } } return line; }; const re_valid = /^\s*(?:'[^'\\]*(?:\\[\S\s][^'\\]*)*'|"[^"\\]*(?:\\[\S\s][^"\\]*)*"|[^,'"\s\\]*(?:\s+[^,'"\s\\]+)*)\s*(?:,\s*(?:'[^'\\]*(?:\\[\S\s][^'\\]*)*'|"[^"\\]*(?:\\[\S\s][^"\\]*)*"|[^,'"\s\\]*(?:\s+[^,'"\s\\]+)*)\s*)*$/; const re_value = /(?!\s*$)\s*(?:'([^'\\]*(?:\\[\S\s][^'\\]*)*)'|"([^"\\]*(?:\\[\S\s][^"\\]*)*)"|([^,'"\s\\]*(?:\s+[^,'"\s\\]+)*))\s*(?:,|$)/g; const CSVtoArray = text => { if (!re_valid.test(text)) { text = tryFixingLine(text); if (!re_valid.test(text)) return null; } const a = []; text.replace(re_value, (m0, m1, m2, m3) => { if (m1 !== undefined) a.push(m1.replace(/\\'/g, '\'')); else if (m2 !== undefined) a.push(m2.replace(/\\"/g, '"').replace(/\\'/g, '\'')); else if (m3 !== undefined) a.push(m3); return ''; }); if ((/,\s*$/).test(text)) a.push(''); return a; }; const getHTTPOptions = downloadUrl => { const parsedUrl = new URL(downloadUrl); const options = { protocol: parsedUrl.protocol, hostname: parsedUrl.hostname, port: parsedUrl.port ? Number.parseInt(parsedUrl.port, 10) : undefined, path: parsedUrl.pathname + parsedUrl.search, headers: { 'User-Agent': UserAgent }, }; const proxy = process.env.http_proxy || process.env.https_proxy; if (proxy) { try { const HttpsProxyAgent = require('https-proxy-agent'); options.agent = new HttpsProxyAgent(proxy); } catch (err) { logGlobalError(`Proxy configured but https-proxy-agent is missing: ${err.message}`); process.exit(-1); } } return options; }; const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); const MAX_REDIRECTS = 10; const requestWithRedirect = (downloadUrl, onResponse) => { const executeRequest = (url, redirectCount) => { const req = https.get(getHTTPOptions(url), response => { const status = response.statusCode || 0; if (REDIRECT_STATUS_CODES.has(status)) { if (redirectCount >= MAX_REDIRECTS) { logGlobalError(`Too many redirects (max ${MAX_REDIRECTS})`); response.destroy(); process.exit(1); } const redirectLocation = response.headers.location; if (!redirectLocation) { logGlobalError(`HTTP redirect response without location header [${status}]`); response.destroy(); process.exit(1); } response.resume(); const redirectUrl = new URL(redirectLocation, url).toString(); executeRequest(redirectUrl, redirectCount + 1); return; } onResponse(response, status); }); req.on('error', err => { logGlobalError(`HTTP request failed: ${err.message}`); process.exit(1); }); }; executeRequest(downloadUrl, 0); }; const writeBuffer = (stream, buffer) => new Promise((resolve, reject) => { stream.write(buffer, err => { if (err) reject(err); else resolve(); }); }); const closeWriteStream = stream => new Promise((resolve, reject) => { stream.end(err => { if (err) reject(err); else resolve(); }); }); const processDataFileByLine = async (tmpDataFile, onDataLine) => { const input = fs.createReadStream(tmpDataFile, { encoding: 'utf8' }); let pending = ''; let lineNumber = 0; for await (const chunk of input) { pending += chunk; let newlineIndex = pending.indexOf('\n'); while (newlineIndex !== -1) { let line = pending.slice(0, newlineIndex); pending = pending.slice(newlineIndex + 1); if (line.endsWith('\r')) line = line.slice(0, -1); lineNumber++; if (lineNumber !== 1) { await onDataLine(line); } newlineIndex = pending.indexOf('\n'); } } if (pending.length > 0) { let line = pending; if (line.endsWith('\r')) line = line.slice(0, -1); lineNumber++; if (lineNumber !== 1) { await onDataLine(line); } } }; const check = (database, cb) => { if (args.indexOf('force') !== -1) { logStepInfo(database, 1, 'Force mode enabled, skipping checksum verification'); return cb(null, database); } const checksumUrl = database.checksum; if (typeof checksumUrl === 'undefined') return cb(null, database); fs.readFile(path.join(dataPath, `${database.type}.checksum`), { encoding: 'utf8' }, (err, data) => { if (!err && data && data.length) database.checkValue = data; logStepInfo(database, 1, `Checking checksum for ${database.fileName}...`); requestWithRedirect(checksumUrl, (response, status) => { if (status !== 200) { logStepError(database, 1, `HTTP request failed [${status} ${http.STATUS_CODES[status]}]`); response.destroy(); process.exit(1); } let str = ''; response.on('data', chunk => { str += chunk; }); response.on('end', () => { if (str && str.length) { if (str === database.checkValue) { logStepInfo(database, 1, 'Checksum unchanged, skipping download, extraction, processing and checksum write'); database.skip = true; } else { logStepInfo(database, 1, 'New data detected, continuing with update...'); database.checkValue = str; } } else { logStepError(database, 1, 'Could not retrieve checksum, aborting...'); logStepError(database, 1, 'Use "force" to bypass checksum validation'); response.destroy(); process.exit(1); } cb(null, database); }); }); }); }; const fetch = (database, cb) => { if (database.skip) return cb(null, null, null, database); const downloadUrl = database.url; let fileName = database.fileName; const gzip = path.extname(fileName) === '.gz'; if (gzip) fileName = fileName.replace('.gz', ''); const tmpFile = path.join(tmpPath, fileName); if (fs.existsSync(tmpFile)) { logStepInfo(database, 2, `Reusing cached download: ${fileName}`); return cb(null, tmpFile, fileName, database); } logStepInfo(database, 2, `Downloading ${fileName}...`); ensureParentDir(tmpFile); requestWithRedirect(downloadUrl, (response, status) => { if (status !== 200) { logStepError(database, 2, `HTTP request failed [${status} ${http.STATUS_CODES[status]}]`); response.destroy(); process.exit(1); } let settled = false; const finish = err => { if (settled) return; settled = true; if (err) { fs.unlink(tmpFile, unlinkErr => { if (unlinkErr && unlinkErr.code !== 'ENOENT') { logStepWarn(database, 2, `Failed to remove incomplete download: ${unlinkErr.message}`); } cb(err); }); return; } cb(null, tmpFile, fileName, database); }; const tmpFileStream = fs.createWriteStream(tmpFile); tmpFileStream.on('error', finish); response.on('error', finish); if (gzip) { const gunzipStream = zlib.createGunzip(); gunzipStream.on('error', finish); response.pipe(gunzipStream).pipe(tmpFileStream); } else { response.pipe(tmpFileStream); } tmpFileStream.on('close', () => finish()); }); }; const extract = (tmpFile, tmpFileName, database, cb) => { if (database.skip) return cb(null, database); if (path.extname(tmpFileName) !== '.zip') { logStepInfo(database, 3, 'Extraction skipped (non-zip file)'); cb(null, database); } else { logStepInfo(database, 3, `Extracting ${tmpFileName}...`); const zip = new AdmZip(tmpFile); const zipEntries = zip.getEntries(); let extractedCount = 0; zipEntries.forEach(entry => { if (entry.isDirectory) return; const filePath = entry.entryName.split('/'); const fileName = filePath[filePath.length - 1]; const destinationPath = path.join(tmpPath, fileName); fs.writeFileSync(destinationPath, entry.getData()); extractedCount++; }); logStepInfo(database, 3, `Extracted ${formatNumber(extractedCount)} files`); cb(null, database); } }; const processLookupCountry = (database, src, cb) => { let loadedRows = 0; let malformedRows = 0; const processLine = line => { const fields = CSVtoArray(line); if (!fields || fields.length < 6) { malformedRows++; logStepWarn(database, 4, `Malformed lookup line skipped: ${toLogPreview(line)}`); return; } loadedRows++; countryLookup[fields[0]] = fields[4]; }; const tmpDataFile = path.join(tmpPath, src); logStepInfo(database, 4, `Building country lookup table from ${src}...`); const rl = readline.createInterface({ input: fs.createReadStream(tmpDataFile, { encoding: 'utf8' }), output: process.stdout, terminal: false, }); let settled = false; const finish = err => { if (settled) return; settled = true; if (err) { cb(err); } else { logStepInfo(database, 4, `Country lookup completed! loaded=${formatNumber(loadedRows)} malformed=${formatNumber(malformedRows)}`); cb(); } }; let lineCount = 0; rl.on('line', line => { if (lineCount > 0) processLine(line); lineCount++; }); rl.on('close', () => finish()); rl.on('error', finish); }; const processCountryData = async (database, ipFamily, src, dest) => { let processedRows = 0; let writtenRows = 0; const dataFile = path.join(dataPath, dest); const tmpDataFile = path.join(tmpPath, src); removePathSync(dataFile); ensureParentDir(dataFile); logStepInfo(database, 4, `Processing ${ipFamily}: source=${src}; output=${dest}`); const progress = createProgressLogger(database, 4, `Processing ${ipFamily}`); const datFile = fs.createWriteStream(dataFile); const processLine = async line => { const fields = CSVtoArray(line); if (!fields || fields.length < 6) { logStepWarn(database, 4, `Malformed ${ipFamily} line skipped: ${toLogPreview(line)}`); return; } processedRows++; let sip; let eip; const cc = countryLookup[fields[1]]; let b; let bsz; let i; if (cc) { if (fields[0].match(/:/)) { bsz = 34; [sip, eip] = ipv6RangeFromCidr(fields[0]); b = Buffer.alloc(bsz); for (i = 0; i < sip.length; i++) { b.writeUInt32BE(sip[i], i * 4); } for (i = 0; i < eip.length; i++) { b.writeUInt32BE(eip[i], 16 + (i * 4)); } } else { bsz = 10; [sip, eip] = ipv4RangeFromCidr(fields[0]); b = Buffer.alloc(bsz); b.writeUInt32BE(sip, 0); b.writeUInt32BE(eip, 4); } b.write(cc, bsz - 2); await writeBuffer(datFile, b); writtenRows++; } progress.maybeLog(processedRows, writtenRows); }; let processingError = null; try { await processDataFileByLine(tmpDataFile, processLine); } catch (err) { processingError = err; } await closeWriteStream(datFile); if (processingError) throw processingError; progress.done(processedRows, writtenRows); }; const processCityData = async (database, ipFamily, src, dest) => { let processedRows = 0; let writtenRows = 0; const dataFile = path.join(dataPath, dest); const tmpDataFile = path.join(tmpPath, src); removePathSync(dataFile); ensureParentDir(dataFile); logStepInfo(database, 4, `Processing ${ipFamily}: source=${src}; output=${dest}`); const progress = createProgressLogger(database, 4, `Processing ${ipFamily}`); const datFile = fs.createWriteStream(dataFile); const processLine = async line => { if (line.match(/^Copyright/) || !line.match(/\d/)) return; const fields = CSVtoArray(line); if (!fields || fields.length < 10) { logStepWarn(database, 4, `Malformed ${ipFamily} line skipped: ${toLogPreview(line)}`); return; } let sip; let eip; let locId; let b; let bsz; let lat; let lon; let area; let i; processedRows++; if (fields[0].match(/:/)) { let offset = 0; bsz = 48; [sip, eip] = ipv6RangeFromCidr(fields[0]); locId = parseInt(fields[1], 10); locId = cityLookup[locId]; b = Buffer.alloc(bsz); for (i = 0; i < sip.length; i++) { b.writeUInt32BE(sip[i], offset); offset += 4; } for (i = 0; i < eip.length; i++) { b.writeUInt32BE(eip[i], offset); offset += 4; } b.writeUInt32BE(locId >>> 0, 32); lat = Math.round(parseFloat(fields[7]) * 10000); lon = Math.round(parseFloat(fields[8]) * 10000); area = parseInt(fields[9], 10); b.writeInt32BE(lat, 36); b.writeInt32BE(lon, 40); b.writeInt32BE(area, 44); } else { bsz = 24; [sip, eip] = ipv4RangeFromCidr(fields[0]); locId = parseInt(fields[1], 10); locId = cityLookup[locId]; b = Buffer.alloc(bsz); b.writeUInt32BE(sip >>> 0, 0); b.writeUInt32BE(eip >>> 0, 4); b.writeUInt32BE(locId >>> 0, 8); lat = Math.round(parseFloat(fields[7]) * 10000); lon = Math.round(parseFloat(fields[8]) * 10000); area = parseInt(fields[9], 10); b.writeInt32BE(lat, 12); b.writeInt32BE(lon, 16); b.writeInt32BE(area, 20); } await writeBuffer(datFile, b); writtenRows++; progress.maybeLog(processedRows, writtenRows); }; let processingError = null; try { await processDataFileByLine(tmpDataFile, processLine); } catch (err) { processingError = err; } await closeWriteStream(datFile); if (processingError) throw processingError; progress.done(processedRows, writtenRows); }; const processCityDataNames = (database, src, dest, cb) => { let locId = null; let linesCount = 0; let malformedRows = 0; const dataFile = path.join(dataPath, dest); const tmpDataFile = path.join(tmpPath, src); removePathSync(dataFile); logStepInfo(database, 4, `Processing city names: source=${src}; output=${dest}`); const datFile = fs.openSync(dataFile, 'w'); const processLine = (line) => { if (line.match(/^Copyright/) || !line.match(/\d/)) return; const b = Buffer.alloc(88); const fields = CSVtoArray(line); if (!fields) { malformedRows++; logStepWarn(database, 4, `Malformed city names line skipped: ${toLogPreview(line)}`); return; } locId = parseInt(fields[0]); cityLookup[locId] = linesCount; const cc = fields[4]; const rg = fields[6]; const city = fields[10]; const metro = parseInt(fields[11]); const tz = fields[12]; const isEuFlag = fields[13]; b.write(cc, 0); b.write(rg, 2); if (metro) b.writeInt32BE(metro, 5); b.write(isEuFlag, 9); b.write(tz, 10); b.write(city, 42); fs.writeSync(datFile, b, 0, b.length, null); linesCount++; }; const rl = readline.createInterface({ input: fs.createReadStream(tmpDataFile, { encoding: 'utf8' }), output: process.stdout, terminal: false, }); let settled = false; const finish = (err) => { if (settled) return; settled = true; fs.closeSync(datFile); if (err) { cb(err); } else { logStepInfo(database, 4, `City names completed! written=${formatNumber(linesCount)} malformed=${formatNumber(malformedRows)}`); cb(); } }; let lineCount = 0; rl.on('line', line => { if (lineCount > 0) processLine(line); lineCount++; }); rl.on('close', () => finish()); rl.on('error', finish); }; const processData = (database, cb) => { if (database.skip) return cb(null, database); const type = database.type; const src = database.src; const dest = database.dest; if (type === 'country') { processLookupCountry(database, src[0], err => { if (err) return cb(err); processCountryData(database, 'country IPv4 data', src[1], dest[1]).then(() => { return processCountryData(database, 'country IPv6 data', src[2], dest[2]); }).then(() => { cb(null, database); }).catch(cb); }); } else if (type === 'city') { processCityDataNames(database, src[0], dest[0], err => { if (err) return cb(err); processCityData(database, 'city IPv4 data', src[1], dest[1]).then(() => { return processCityData(database, 'city IPv6 data', src[2], dest[2]); }).then(() => { cb(null, database); }).catch(cb); }); } else { cb(new Error(`Unknown database type: "${type}"`)); } }; const updateChecksum = (database, cb) => { if (database.skip || !database.checkValue) return cb(); fs.writeFile(path.join(dataPath, database.type + '.checksum'), database.checkValue, 'utf8', err => { if (err) logStepError(database, 5, `Failed to write checksum: ${err.message}`); cb(); }); }; if (!license_key) { logGlobalError('Missing license_key'); process.exit(1); } removePathSync(tmpPath); fs.mkdirSync(tmpPath, { recursive: true }); const invokeStep = (fn, ...stepArgs) => new Promise((resolve, reject) => { fn(...stepArgs, (err, ...results) => { if (err) reject(err); else resolve(results); }); }); const run = async () => { const totalDatabases = databases.length; for (const [index, database] of databases.entries()) { database._part = index + 1; database._totalParts = totalDatabases; const startedAt = Date.now(); logPipelineInfo(database, `Starting update (${database.fileName})!`); const [checkedDatabase] = await invokeStep(check, database); const [tmpFile, tmpFileName, fetchedDatabase] = await invokeStep(fetch, checkedDatabase); const [extractedDatabase] = await invokeStep(extract, tmpFile, tmpFileName, fetchedDatabase); const [processedDatabase] = await invokeStep(processData, extractedDatabase); await invokeStep(updateChecksum, processedDatabase); logPipelineInfo(database, `Finished update in ${formatDuration(Date.now() - startedAt)}`); } }; const runStartedAt = Date.now(); run() .then(() => { log.success(`[${databases.length}/${databases.length}] All databases were successfully updated in ${formatDuration(Date.now() - runStartedAt)}!`); if (args.indexOf('debug') !== -1) { logGlobalInfo(`Debug mode enabled. Temporary files preserved at ${tmpPath}.`); } else { removePathSync(tmpPath); } process.exit(0); }) .catch(err => { logGlobalError(`Failed to update databases from MaxMind: ${err?.stack || err?.message || String(err)}`); process.exit(1); });