const axios = require('axios'); const net = require('net'); const dns = require('dns').promises; const db = require('./db'); /** * Geo Location Service * Resolves IP addresses to geographical coordinates and country info. * Caches results in the database to minimize API calls. */ const ipInfoToken = process.env.IPINFO_TOKEN; const enableExternalGeoLookup = process.env.ENABLE_EXTERNAL_GEO_LOOKUP === 'true'; /** * Normalizes geo data for consistent display */ function normalizeGeo(geo) { if (!geo) return geo; // Custom normalization for TW to "Taipei, China" and JP to "Tokyo" const country = (geo.country || geo.country_code || '').toUpperCase(); if (country === 'TW') { return { ...geo, city: 'Taipei', country: 'TW', country_name: 'China', // Force Taipei coordinates for consistent 2D plotting loc: '25.0330,121.5654', latitude: 25.0330, longitude: 121.5654 }; } else if (country === 'JP') { return { ...geo, city: 'Tokyo', country: 'JP', country_name: 'Japan', // Force Tokyo coordinates for consistent 2D plotting loc: '35.6895,139.6917', latitude: 35.6895, longitude: 139.6917 }; } return geo; } async function getLocation(target) { // Normalize target (strip port if present, handle IPv6 brackets) let cleanTarget = target; if (cleanTarget.startsWith('[')) { const closingBracket = cleanTarget.indexOf(']'); if (closingBracket !== -1) { cleanTarget = cleanTarget.substring(1, closingBracket); } } else { const parts = cleanTarget.split(':'); if (parts.length === 2) { cleanTarget = parts[0]; } } // 1. Check if we already have this IP/Domain in DB (FASTEST) try { const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanTarget]); if (rows.length > 0) { const data = rows[0]; const age = Date.now() - new Date(data.last_updated).getTime(); if (age < 30 * 24 * 60 * 60 * 1000) { return normalizeGeo(data); } } } catch (err) { // console.error(`[Geo Service] DB check failed for ${cleanTarget}`); } // 2. Resolve domain to IP if needed let cleanIp = cleanTarget; if (net.isIP(cleanTarget) === 0) { try { const lookup = await dns.lookup(cleanTarget); cleanIp = lookup.address; // Secondary DB check with resolved IP const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanIp]); if (rows.length > 0) { const data = rows[0]; // Cache the domain mapping to avoid future DNS lookups if (cleanTarget !== cleanIp) { try { await db.query(` INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE last_updated = CURRENT_TIMESTAMP `, [cleanTarget, data.country, data.country_name, data.region, data.city, data.latitude, data.longitude]); } catch(e) {} } return normalizeGeo(data); } } catch (err) { // Quiet DNS failure for tokens (legacy bug mitigation) if (!/^[0-9a-f]{16}$/i.test(cleanTarget)) { console.error(`[Geo Service] DNS resolution failed for ${cleanTarget}:`, err.message); } return null; } } // 3. Skip local/reserved IPs if (isLocalIp(cleanIp)) { return null; } // 4. Resolve via ipinfo.io (LAST RESORT) if (!enableExternalGeoLookup) { return null; } try { console.log(`[Geo Service] API lookup (ipinfo.io) for: ${cleanIp}`); const url = `https://ipinfo.io/${cleanIp}/json${ipInfoToken ? `?token=${ipInfoToken}` : ''}`; const response = await axios.get(url, { timeout: 5000 }); const geo = normalizeGeo(response.data); if (geo && geo.loc) { const [lat, lon] = geo.loc.split(',').map(Number); const locationData = { ip: cleanIp, country: geo.country, country_name: geo.country_name || geo.country, // ipinfo might not have country_name in basic response region: geo.region, city: geo.city, latitude: lat, longitude: lon }; // Save to DB await db.query(` INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE country = VALUES(country), country_name = VALUES(country_name), region = VALUES(region), city = VALUES(city), latitude = VALUES(latitude), longitude = VALUES(longitude) `, [ locationData.ip, locationData.country, locationData.country_name, locationData.region, locationData.city, locationData.latitude, locationData.longitude ]); // Cache the domain target as well if it differs from the resolved IP if (cleanTarget !== cleanIp) { await db.query(` INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE country = VALUES(country), country_name = VALUES(country_name), region = VALUES(region), city = VALUES(city), latitude = VALUES(latitude), longitude = VALUES(longitude) `, [ cleanTarget, locationData.country, locationData.country_name, locationData.region, locationData.city, locationData.latitude, locationData.longitude ]); } return locationData; } } catch (err) { console.error(`[Geo Service] Error resolving IP ${cleanIp}:`, err.message); } return null; } function isLocalIp(ip) { if (ip === 'localhost' || ip === '127.0.0.1' || ip === '::1') return true; // RFC1918 private addresses const p1 = /^10\./; const p2 = /^172\.(1[6-9]|2[0-9]|3[0-1])\./; const p3 = /^192\.168\./; return p1.test(ip) || p2.test(ip) || p3.test(ip); } module.exports = { getLocation };