简介:get-public-ip 是一个面向 Node.js 开发者的轻量级公共 IP 查询模块,通过 DNS 解析以及 HTTP、HTTPS 请求组合获取客户端公网 IPv4 地址,适合需要在服务端快速探测出口 IP、做网络诊断或环境校验的开发者使用。其特点是兼容范围较广,可在 Node.js 0.10.x 至 8.11.x 等多个版本上运行,也兼顾仍在旧版运行时上的嵌入式场景。压缩包共 6 个文件,约 4KB,包含 2 个 JavaScript 源码与 2 个 JSON 配置文件,另有许可证和说明文档,分别承担核心解析逻辑、服务与依赖配置、授权声明及使用说明,体量虽小但结构完整。已有 175 人学习,读者可从中了解多解析器回退的实现思路,参考 Google DNS 等公共服务的调用方式,并直接复用获取公网 IP 的脚本或按需扩展 DNS、HTTP、HTTPS 三种探测路径,也可作为学习 Node.js 网络编程与兼容性处理的示例。
1. 一个被忽略的小需求:容器里为什么总是拿不到真实出口 IP
前阵子帮一个做日志归集的小团队排查问题:服务跑在容器编排里,写入数据库的客户端 IP 全是 10.x 开头的内网地址,风控规则一条都没命中。他们试过os.networkInterfaces(),拿回来的是网卡地址不是出口地址;也试过在启动脚本里curl ifconfig.me,结果精简镜像里压根没装 curl,脚本直接以非零码退出,把健康检查也带挂了。最后落到一个 nodejs 小模块上:get-public-ip,用 dns 查询公共解析服务的回显域名拿到公网 ipv4-address,DNS 走不通再切 HTTP 兜底,两条通道并发竞速,谁先回谁算数。这类模块代码量不大,但把「多源、超时、并发、结果归一」这几件事做扎实,比随手抄一行 curl 可靠得多。它适合写 JavaScript 后端或运维脚本、需要在运行时动态知道自己出口 IP 的人,也适合想拿一个真实小项目练手 dns 模块用法的人。
2. 项目骨架与技术选型:DNS 回显 + HTTP 兜底的双通道
先回答一个必然会被问到的问题:查公网 IP 明明一行 HTTP 请求就够,为什么要折腾 DNS。原因有三。第一,HTTP 通道依赖目标站点活着,免费的回显服务隔三差五改限流策略,脚本半夜挂掉很难查;DNS 查询打的是公共解析服务,生命周期长得多。第二,HTTP 请求要过本机的证书链校验和 HTTP 客户端,容器里证书包过期、NODE_EXTRA_CA_CERTS没配对,都会先于业务逻辑报错;DNS 走 UDP 53,链路短,出问题时错误码也更容易定位。第三,DNS 通道拿到的地址是「解析服务看到的来源地址」,语义上更贴近出口地址,不像某些 HTTP 服务会因为 CDN 回源拿到中间层地址。
代价是 DNS 通道不是所有网络环境都通。企业内网把 53 端口劫持到本地解析器、公有云安全组只放行 443,这两种情况都很常见。所以这个模块的设计从一开始就是双通道:DNS 优先,HTTP 兜底,两边并发跑,谁快用谁。
2.1 目录结构与模块职责
模块拆得比较克制,一个入口加四个职责单一的 lib 文件,加一个 CLI,测试只覆盖纯函数部分。这种规模的项目最容易犯的错是把所有逻辑塞进 index.js,等到要加缓存、加自定义数据源的时候全得推倒重来。
get-public-ip/ ├── package.json ├── index.js # 对外 API,编排调度 ├── lib/ │ ├── dns-source.js # DNS 通道:源配置 + Resolver 封装 │ ├── http-source.js # HTTP 通道:源配置 + 请求封装 │ ├── normalize.js # 结果归一化:A/TXT 摊平、格式校验、族过滤 │ └── cache.js # 可选的结果缓存(二次开发时接入) ├── bin/ │ └── cli.js # 命令行入口,输出纯文本或 JSON └── test/ └── normalize.test.js # 只测归一化,不依赖网络各模块的分工边界值得说清楚:
| 文件 | 输入 | 输出 | 是否碰网络 |
|---|---|---|---|
dns-source.js | 源配置、timeout | 原始记录数组 | 是 |
http-source.js | 源配置、timeout | 响应正文字符串 | 是 |
normalize.js | 原始记录、family | 合法 IP 字符串数组 | 否 |
cache.js | key、value | 无 | 否 |
index.js | 用户 options | 单个 IP 字符串 | 间接 |
把「归一化」单独拎出来是这套结构里最关键的一刀。它让normalize.js变成一个纯函数,测试里直接喂字符串就能跑,不需要起 mock server,也不需要nock之类的网络拦截库。做二次开发时想加新的 DNS 源,只要它最终能吐出一个字符串数组,归一化这一层完全不用动。
2.2 DNS 通道:把公网 IP 塞进解析结果
DNS 通道的原理说破了很简单:有些公共解析服务商提供「回显域名」,你拿它的解析器去查这个域名,它不返回业务地址,而是把「发起这次查询的来源 IP」塞进应答里返回。查 A 记录就得到一个 IPv4 地址,查 TXT 记录就得到一个带引号的字符串。
模块内置了两类源,配置写成一张表,加源就是往数组里 push 一个对象:
// lib/dns-source.js —— 源配置 const DNS_SOURCES = [ { id: 'opendns-a', record: 'A', // 查 A 记录 host: 'myip.opendns.com', // 回显域名 servers: ['208.67.222.222', '208.67.220.220'], }, { id: 'google-txt', record: 'TXT', // 查 TXT 记录 host: 'o-o.myaddr.l.google.com', servers: ['216.239.32.10', '216.239.34.10'], }, ];servers这一项是必须显式设置的。如果直接把回显域名丢给系统默认解析器,因为系统解析器通常会去问本地 DNS,而本地 DNS 看到的来源是它自己或者你的内网网关,返回的就是内网地址——这是最常见的翻车点,后面避坑章节会展开。所以正确做法是用dns.Resolver新建一个解析器实例,setServers()指向公共解析服务,让查询报文从你的机器直接打到对端。
每个源给两个 IP 是有意为之的。DNS 是 UDP,丢一个包不会自动重传,两个地址相当于在源这一层先做一次冗余;tries参数再兜一次重试,两级加起来通常就够了。别把servers塞到四五个,收益递减,反而拉长最坏情况下的等待时间。
2.3 HTTP 通道:为什么必须留一条后路
HTTP 源挑的是「返回纯文本 IP」这类接口,响应体只有一行,不需要解析 JSON,也不依赖任何 SDK。JSON 格式的接口在字段名上容易变,纯文本接口反而更皮实:
// lib/http-source.js —— 源配置 const HTTP_SOURCES = [ { id: 'ipify', url: 'https://api.ipify.org' }, { id: 'ipinfo', url: 'https://ipinfo.io/ip' }, { id: 'icanhazip', url: 'https://icanhazip.com' }, ];请求封装上有个细节必须处理:https.get的timeout选项只作用于 socket 空闲超时,不会中断已经建立但迟迟不返回响应体的连接。所以除了传timeout,还要在timeout事件里主动req.destroy(),否则目标站挂起不响应时,Promise 会一直悬着,整个并发编排卡在那里,前面的 DNS 通道再快也没用。
function queryHttp(source, { timeout = 2000 } = {}) { return new Promise((resolve, reject) => { const req = https.get(source.url, { timeout }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { body += chunk; }); res.on('end', () => resolve(body.trim())); }); // 关键:timeout 只标记超时,不销毁连接,必须手动 destroy req.on('timeout', () => req.destroy(new Error('ETIMEDOUT'))); req.on('error', reject); }); }三个源并发发出,不做串行重试。串行重试看着「省流量」,实际上把最坏延迟从「一个源超时」放大到「三个源全超时」,在容器启动阶段这种代码特别容易被上层健康检查判死。
3. 核心实现拆解:解析器、并发竞速与结果归一
这一章是模块真正的骨架,四个关注点依次是:解析器怎么建、多源怎么并发、结果怎么洗、对外 API 长什么样。每一层都可以单独替换,二次开发的时候按需动刀就行。
3.1 自建 Resolver 与超时参数
dns.Resolver的构造签名在不同 Node 版本上行为不一致,这是踩过的坑:new dns.Resolver({ timeout, tries })里的这两个选项是较新版本才支持的,在老版本上会被静默忽略,你以为设了 1.5 秒超时,实际等了一个系统默认值。所以封装时把版本差异挡在外面,业务层只看一个统一的timeout。
// lib/dns-source.js const dns = require('dns'); function createResolver(servers, timeout, tries) { const resolver = new dns.Resolver({ timeout, tries }); resolver.setServers(servers); // 必须显式指定,否则查到的是内网地址 return resolver; } function queryDns(source, { timeout = 1500, tries = 1 } = {}) { return new Promise((resolve, reject) => { const resolver = createResolver(source.servers, timeout, tries); const onDone = (err, records) => (err ? reject(err) : resolve(records)); if (source.record === 'A') { resolver.resolve4(source.host, onDone); // 回调第二参:['1.2.3.4'] } else { resolver.resolveTxt(source.host, onDone); // 回调第二参:[['1.2.3.4']] } }); } module.exports = { DNS_SOURCES, queryDns };参数含义按重要性排:servers决定这次查询打到哪,timeout是单次查询的等待上限,tries是同一解析器内对同一服务器的重试次数。timeout默认给 1500 毫秒是有讲究的——跨运营商 UDP 往返通常在 30 到 120 毫秒,给到 1.5 秒已经覆盖了绝大多数抖动,再往上加只会让 HTTP 兜底迟迟拿不到执行机会。
如果项目要跑在长期驻留的进程里,每次查询都 new 一个 Resolver 会不断创建 UDP socket。我的做法是在模块级复用一个实例,配一个 Map 按servers的 join 结果做缓存,进程退出时统一cancel()掉。
3.2 多源并发:用 Promise.any 拿第一个成功
并发编排这块,Promise.any是最贴合的语义:所有 Promise 里只要有一个 fulfilled 就立刻返回,全部 rejected 才抛AggregateError。相比之下Promise.race有致命问题——它取的是「第一个 settled」,如果第一个完成的是个失败(DNS 被拦),整个调用直接就 reject 了,HTTP 通道明明还在路上也没机会。
// index.js const { DNS_SOURCES, queryDns } = require('./lib/dns-source'); const { HTTP_SOURCES, queryHttp } = require('./lib/http-source'); const { normalize } = require('./lib/normalize'); async function getPublicIp(options = {}) { const { timeout = 2000, family = 4, sources = 'all' } = options; const tasks = []; if (sources !== 'http') { for (const src of DNS_SOURCES) { tasks.push( queryDns(src, { timeout }) .then((records) => normalize(records, family)) .then((list) => { if (!list.length) throw new Error(`${src.id}: 无合法地址`); return list[0]; }) ); } } if (sources !== 'dns') { for (const src of HTTP_SOURCES) { tasks.push( queryHttp(src, { timeout }).then((text) => { const list = normalize([text], family); if (!list.length) throw new Error(`${src.id}: 响应不是合法 IP`); return list[0]; }) ); } } return Promise.any(tasks); }Promise.any的用法里有个容易忽略的细节:每个任务在 resolve 之前必须自己校验一遍,空数组也要当成失败抛出去。因为normalize在记录不合法时返回空数组,如果直接把空数组 resolve 出去,Promise.any会认为这个源成功了,返回一个空结果,然后你在业务层拿到一个 undefined,排查起来非常费劲。
sources这个选项是留给排障用的。线上怀疑 DNS 通道被拦,把sources: 'http'一传,绕过 DNS 直接验证 HTTP 通道是否正常,比改代码加日志快得多。这类开关在运维脚本里属于「后悔药」级别的设计,强烈建议每个多源模块都留一个。
3.3 结果归一化:A 记录、TXT 记录与 IPv6 过滤
归一化这一层看着琐碎,其实是整个模块最容易出问题的地方。resolve4和resolveTxt的返回结构完全不同,HTTP 通道返回的是带换行的字符串,三路数据必须洗干净才能交给上层。
// lib/normalize.js function isIPv4(s) { const parts = s.split('.'); if (parts.length !== 4) return false; return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255); } function isIPv6(s) { return /^[0-9a-f:]+$/i.test(s) && s.includes(':'); } function normalize(records, family = 4) { const list = Array.isArray(records) ? records : [records]; // resolveTxt 返回 [['分段1','分段2']],A 记录返回 ['1.2.3.4'],HTTP 返回换行文本 const flat = list.map((item) => (Array.isArray(item) ? item.join('') : item)); return flat .map((s) => String(s).trim().replace(/^"|"$/g, '')) // 去掉 TXT 记录自带的引号 .map((s) => s.replace(/^::ffff:/i, '')) // 剥掉 IPv4 映射前缀 .filter((s) => (family === 4 ? isIPv4(s) : isIPv6(s))); } module.exports = { normalize, isIPv4, isIPv6 };三处处理逐条说。join('')是为了应对 TXT 记录被切块的情况:DNS 的 TXT 记录单个字符串上限是 255 字节,长值会被拆成多个 chunk,resolveTxt原样返回二维数组,不拼回来就得不到完整地址。replace(/^"|"$/g, '')是去掉记录值外面的引号,TXT 记录在 DNS 报文里是带引号的字符字符串,Node 不会帮你剥。replace(/^::ffff:/i, '')针对双栈机器,连接层可能给回一个 IPv4 映射地址,前缀不去掉,isIPv4校验必然失败。
isIPv4里做了逐段范围校验,而不是简单的正则匹配。只用\d{1,3}的话,999.1.1.1也能过,服务端偶尔吐脏数据时,这个校验能挡掉一批。做这块我当时也是踩了坑,一开始只用正则^(\d{1,3}\.){3}\d{1,3}$,结果测试里构造的非法样本全被放行,单元测试成了摆设。
3.4 对外 API 的形态:Promise 与 callback 双签名
对外接口只暴露一个函数,返回 Promise,同时兼容老式的 callback 风格。这不是为了好看,是实际迁移成本问题——很多脚本里还在getPublicIp(cb),一刀切成 Promise 会导致调用方全面改写。
// index.js 尾部的签名兼容 function getPublicIpApi(options, callback) { if (typeof options === 'function') { callback = options; options = {}; } const p = getPublicIp(options); if (typeof callback === 'function') { p.then((ip) => callback(null, ip), (err) => callback(err)); return; } return p; } module.exports = getPublicIpApi; module.exports.getPublicIp = getPublicIpApi;双签名这里必须注意一点:传了 callback 时函数返回undefined,不能再返回同一个 Promise。否则调用方写了await getPublicIp({}, cb),Promise 和 callback 各触发一次,日志里会看到两条记录,很容易误判成模块被调了两次。
4. 跑起来:安装、最小示例与三种验证方式
模块本身零运行时依赖,只用到 Node 内置的dns和https,所以装完就能跑,不用等编译。Node 版本建议 16 以上,主要卡在Promise.any和dns.Resolver的构造选项上;如果线上环境锁死在 14,得自己补一个AggregateError的 polyfill 和一个手写的 any 实现。
4.1 安装与最小可运行示例
# 本地安装 npm install get-public-ip # 或者不装,直接跑一次 npx get-public-ip最小可运行示例只要三行:
// demo.js const getPublicIp = require('get-public-ip'); getPublicIp({ timeout: 2000, family: 4 }) .then((ip) => console.log('出口 IP:', ip)) .catch((err) => { // AggregateError 的错误信息要展开看 const detail = err.errors ? err.errors.map((e) => e.message).join(' | ') : err.message; console.error('全部数据源失败:', detail); process.exitCode = 1; });timeout传 2000 意味着单个源最多等 2 秒,因为所有源并发,总耗时上限差不多就是 2 秒多一点。生产环境里如果你能接受稍长的等待,可以调到 3000,代价是容器冷启动时的首屏判断会慢一点。
family是 4 还是 6 直接决定归一化时用哪套校验规则。默认 4,因为绝大多数场景关心的是 IPv4 出口地址;要拿 IPv6 地址必须显式传 6,而且前提是你的机器和解析服务之间真的走了 IPv6,否则回显域名返回的还是 v4 地址,会被 family 过滤掉,最后拿到空数组。
4.2 CLI 用法与退出码约定
CLI 是这类小模块的加分项,运维排查时不用现写脚本:
# 默认输出纯文本 IP npx get-public-ip # 输出 JSON,方便被 jq 之类的工具消费 npx get-public-ip --json # 只走 HTTP 通道,验证 DNS 是不是被拦了 npx get-public-ip --sources http # 指定族和超时 npx get-public-ip --family 6 --timeout 3000CLI 里我习惯约定三个退出码:0 表示成功拿到 IP,1 表示所有源都失败,2 表示参数错误。运维脚本里if [ $? -eq 1 ]就能区分「网络不通」和「命令写错了」,不用去解析 stderr 文本。这块属于血泪经验,之前有个巡检脚本把所有非零退出码都当成同一个告警,结果参数写错和真的断网混在一条告警里,排查了半天。
4.3 分环境验证清单
不同环境下的失败姿势差别很大,我一般按下面的清单逐项过一遍,基本能定位到问题出在哪一层:
| 环境 | 常见现象 | 优先验证 |
|---|---|---|
| 本地开发机 | 一切正常,DNS 通道秒回 | 无需处理,作为基线 |
| 容器(bridge 网络) | 拿到 172.17.x.x | 检查是否需要显式指定servers |
| 容器(host 网络) | 正常,与宿主机一致 | 对比宿主机直查结果 |
| 企业内网 | DNS 通道全部超时 | --sources http验证 HTTP 通道 |
| 双栈机器 | 返回::ffff:x.x.x.x或空 | 显式传family,检查前缀剥离 |
| 长期驻留进程 | 跑几小时后开始失败 | 检查 Resolver 是否泄漏、是否复用 |
验证 DNS 通道通不通,最直接的办法是用系统工具绕过模块自己查一次:
# 直接指定公共解析器查回显域名 dig @208.67.222.222 myip.opendns.com A +short # 查 TXT 回显,注意输出会带引号 dig @216.239.32.10 o-o.myaddr.l.google.com TXT +short如果dig能拿到正确的公网地址,而模块拿不到,问题一定在代码层,重点看setServers有没有生效;如果dig也超时,那就是网络层的事,直接切 HTTP 通道,不要在 DNS 上死磕。
5. 避坑与排查:五类常见翻车现场
这个模块代码加起来不到三百行,但线上报障的姿势五花八门。下面五条是按出现频率排的,每条都按现象、原因、解决的顺序写,照着对号入座基本能覆盖九成情况。
5.1 DNS 查询直接报 ENOTFOUND 或 ESERVFAIL
现象:调用直接 reject,错误信息是queryA ENOTFOUND myip.opendns.com或者queryA ESERVFAIL,HTTP 通道其实还在跑,但因为你没展开AggregateError,看到的只有一个失败。
原因:设了自定义servers,但出口网络的 UDP 53 被安全组或者内网策略拦了;另一种情况是内网强制把 53 端口劫持到本地解析器,包发出去了但对方不认识这个回显域名,直接回 SERVFAIL。
解决:先用dig @208.67.222.222 myip.opendns.com A从同一台机器验证,不通就走sources: 'http'。如果必须保留 DNS 通道,可以评估改用基于 HTTPS 的解析方式,走 443 端口绕过端口限制;但这方案要看你的 Node 版本和运行环境是否允许出网,不要默认可用。
5.2 TXT 结果带引号,IP 校验一直过不去
现象:日志里明明打印出了正确的地址"203.0.113.7",但normalize返回空数组,Promise.any一路 reject。
原因:resolveTxt返回的是二维数组,形如[['203.0.113.7']],而且记录值在 DNS 报文里本身就带引号;记录长的时候还会被切成多个 chunk,只取第一段就得不到完整值。
解决:先join('')拼回完整字符串,再replace(/^"|"$/g, '')剥引号,两步顺序不能反。补一条单元测试喂[['"203.0.113.7"']],这个坑就不会再复现。
5.3 超时参数设了不生效,请求卡十几秒
现象:明明传了timeout: 1500,实际等十几秒才失败,容器健康检查已经判死了。
原因:new dns.Resolver({ timeout, tries })的这两个构造选项是较新 Node 版本才支持的,老版本静默忽略,退回到系统默认超时。另一个可能是你用了dns.resolve4这个顶层方法而不是 Resolver 实例的方法,顶层方法根本不接受servers和timeout。
解决:先用process.version确认版本;老版本上自己包一层setTimeout加resolver.cancel(),超时后主动取消,别指望构造参数。cancel()会让挂起的查询以ECANCELLED拒绝,记得在 Promise 里把这个错误也当失败处理。
5.4 AggregateError 的 message 是空的,排查时毫无头绪
现象:catch到错误,console.log(err.message)打出来是空字符串,err.name是AggregateError,完全不知道是哪个源挂了、挂在哪一步。
原因:Promise.any在全部失败时抛的是AggregateError,它把每个子错误放在err.errors数组里,自身的message是空的。这个设计本身没问题,但如果你的日志只打 message,等于把黑匣子扔了。
解决:封装一个describeError函数,判断err.errors存在就逐个展开name + message,拼成一行再打。CLI 里也走同一个函数,保证脚本和代码的报错口径一致。
5.5 双栈环境下拿到空数组或者带前缀的地址
现象:某些机器上模块返回空数组,或者返回::ffff:203.0.113.7这种带前缀的地址,上层拿到之后net.isIP()判定异常。
原因:双栈环境下 A 记录和 AAAA 记录会混着回来,normalize如果不按family过滤,v4 地址在 v6 校验下必然失败;而连接层在特定场景下会返回 IPv4 映射地址,前缀不剥离就过不了 v4 校验。
解决:normalize里先剥::ffff:前缀再按family过滤,两步的顺序很关键。另外建议在上层再兜一次net.isIP(ip),双重校验的成本几乎为零,能挡掉一批源站返回脏数据的情况。
6. 二次开发:加一层带 TTL 的缓存与自定义数据源
真要把它用进生产,第一件该做的事是加缓存,而不是加数据源。公网 IP 在绝大多数场景下不会秒级变化,容器重建、NAT 网关切换、拨号重连这些事件之间,短则几分钟长则几天。每次打日志、每次上报指标都去查一遍 DNS,量一上来对公共解析服务不礼貌,也白白拉高了调用链尾延迟。
缓存层我一般写成 60 秒 TTL,加一个手动clear():
// lib/cache.js function createTtlCache(ttl = 60_000) { const store = new Map(); return { has(key) { const hit = store.get(key); if (!hit) return false; if (Date.now() - hit.at > ttl) { store.delete(key); return false; } return true; }, get(key) { return store.get(key)?.value; }, set(key, value) { store.set(key, { value, at: Date.now() }); }, clear() { store.clear(); }, }; }接入点在getPublicIp的开头和结尾各加三行:拿到 IP 后cache.set(family, ip),入口处cache.has(family)命中就直接返回。clear()留给外部调用,比如你的服务检测到网络切换事件,主动清一次比等 TTL 过期更及时。注意缓存 key 要用family而不是固定的字符串,否则 v4 和 v6 会互相覆盖,这种 bug 在双栈机器上表现为「偶尔拿到上一台机器的地址」,特别难查。
第二件事是加自定义数据源。因为源配置是纯数据,加一个源不需要改调度逻辑,只要保证新源最终能吐出一个字符串数组就行。比如你在私有云里自建了一个回显接口,直接往HTTP_SOURCES里 push 一个对象,然后在归一化里确认它的响应能被normalize正确处理。如果响应是 JSON,就在queryHttp外面包一层解包再交给normalize,不要动normalize本身的签名——那一层的纯净度是这套结构能不能持续演进的关键。
第三件事是补测试。别测网络,测纯函数。给normalize准备几组样本:标准 A 记录数组、二维 TXT 数组、带引号的 TXT、带::ffff:前缀的映射地址、999.1.1.1这种非法值、空数组。这六组跑通,归一化这一层基本就锁死了。至于并发编排,用Promise.resolve和Promise.reject手工构造几个假任务丢给Promise.any就行,不需要起本地服务器。
最后提一个验证技巧:改完之后开两个终端,一个跑watch -n 5 node demo.js,一个在另一个网络命名空间里跑同样的命令,对比两边的输出是否一致。单机测试永远发现不了出口地址不一样的问题,这个动作我后来做任何「查自己」类模块都会先跑一遍。
从那以后我每次接这类「向外问自己是谁」的需求,都强制先把sources开关和错误展开函数写进去,再动业务代码——出事的时候这两样东西能省下大半天。希望帮到你。
本文还有配套的精品资源,点击获取