Files
PromdataPanel/server/geo-service.js
2026-04-14 16:56:46 +08:00

205 lines
6.1 KiB
JavaScript

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
};