码桶

发现社区成员的开源项目

main
NodeNanny/core/geoip.js
geoip.js4.8 KB
'use strict';

// 流量池自愈生态·第五批·第一步:GeoIP国家码模块。
//
// 定位:给一个节点的服务器地址(可能是IP,也可能是域名),尽力查出它的国家二字码
// (ISO 3166-1 alpha-2,比如"US"/"JP"),用于星图/名册展示国旗。查不出来就返回null,
// 不抛异常、不阻塞流量池主体功能——这是创始人在方案讨论里明确要求的("下载时机...
// 失败就显示'未知'、不影响其它功能",详见交接文档v30续篇1.5节)。
//
// 数据源选择(已跟创始人确认,详见续篇1.5节):@ip-location-db/geo-whois-asn-country-mmdb
// 这个npm包——country级别(不是city级别,体积小一个数量级),PDDL/CC0协议(公有领域,
// 免费、不需要注册账号、不需要在UI上加署名),随项目现有的npm install流程一起装,
// 不需要额外写下载脚本、也不用担心sapics/ip-location-db那次改过GitHub Releases分发
// 方式导致下载链接失效的问题(2026年6月18日那次改动,详见续篇1.5节备注)。
//
// 本轮新增的一个复杂点(设计讨论时没提到,是写代码时才发现的):节点地址不一定是IP,
// 很多aggregator抓来的节点用的是域名——mmdb只能查IP,查域名得先做一次DNS解析。
// 这里的处理方式:
//   1. 如果地址本身就是IP(v4/v6),直接查mmdb,不用走DNS。
//   2. 如果是域名,做一次DNS解析(带超时保护,默认2秒,避免个别域名解析慢拖慢整体
//      刷新流程),解析成功再查mmdb;解析失败/超时,返回null(前端显示"未知")。
//   3. 域名的解析结果按地址本身做内存缓存(不落盘,进程重启后重新查,反正开销很小),
//      避免同一批节点里出现同一个域名时重复发起DNS请求。
//
// 对外只暴露一个函数:resolveCountryCode(hostOrAddress) -> Promise<string|null>

const path = require('path');
const dns = require('dns');
const net = require('net');

const DNS_TIMEOUT_MS = 2000;
const MMDB_RELATIVE_PATH = path.join(
  '..', 'node_modules', '@ip-location-db', 'geo-whois-asn-country-mmdb', 'geo-whois-asn-country.mmdb'
);

let lookupPromise = null; // maxmind Reader 实例的懒加载缓存(只加载一次)
let mmdbUnavailable = false; // 一旦确认加载失败(比如包没装成功),后续直接短路,不重复尝试打开文件
const dnsCache = new Map(); // host -> 解析出的IP(或null,代表解析失败过),进程内存缓存,不落盘

function getLookup() {
  if (mmdbUnavailable) return Promise.resolve(null);
  if (!lookupPromise) {
    lookupPromise = (async () => {
      try {
        const maxmind = require('maxmind');
        const mmdbPath = path.join(__dirname, MMDB_RELATIVE_PATH);
        return await maxmind.open(mmdbPath);
      } catch (err) {
        // 常见原因:npm install没装成功、或者maxmind包缺失。这里不抛异常,只记一次日志,
        // 后续所有查询都直接返回null(不会每次都重复报错刷屏)。
        console.error('[geoip] mmdb加载失败,国家码功能本轮不可用(不影响流量池其它功能):', err.message);
        mmdbUnavailable = true;
        return null;
      }
    })();
  }
  return lookupPromise;
}

function isIpAddress(str) {
  return net.isIP(str) !== 0;
}

// 带超时保护的DNS解析,超时/失败都返回null,不抛异常。
function resolveDnsWithTimeout(hostname) {
  return new Promise((resolve) => {
    let settled = false;
    const timer = setTimeout(() => {
      if (settled) return;
      settled = true;
      resolve(null);
    }, DNS_TIMEOUT_MS);

    dns.lookup(hostname, (err, address) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      resolve(err ? null : address);
    });
  });
}

async function resolveIpFor(hostOrAddress) {
  if (isIpAddress(hostOrAddress)) return hostOrAddress;
  if (dnsCache.has(hostOrAddress)) return dnsCache.get(hostOrAddress);
  const ip = await resolveDnsWithTimeout(hostOrAddress);
  dnsCache.set(hostOrAddress, ip);
  return ip;
}

// 主入口:给一个节点地址(IP或域名),返回国家二字码,查不出来返回null。
// 这个函数本身承诺不抛异常——调用方(pool.js)不需要额外套try/catch,查不出来就是null,
// 不应该因为GeoIP这个锦上添花的功能查失败,就影响流量池主体的节点检测/加权抽取流程。
async function resolveCountryCode(hostOrAddress) {
  if (!hostOrAddress || typeof hostOrAddress !== 'string') return null;
  try {
    const lookup = await getLookup();
    if (!lookup) return null;
    const ip = await resolveIpFor(hostOrAddress);
    if (!ip) return null;
    const result = lookup.get(ip);
    return (result && result.country_code) || null;
  } catch (err) {
    console.error('[geoip] resolveCountryCode 查询失败(已忽略,不影响调用方):', err.message);
    return null;
  }
}

module.exports = { resolveCountryCode };