最近给博客实验室加了一个 IP 查询工具。功能看起来不复杂:输入一个 IPv4,查国家、省份、城市和运营商;再放一个“查询我的 IP”按钮,看看当前浏览器通过哪条网络路径访问公网。

真正上线以后,问题很快就来了。

同一台电脑、同一个浏览器,我在不同地方看到的公网 IP 居然不一样。HTTP 请求是一个地址,WebRTC/STUN 又可能得到另一个地址;请求经过 Nginx、Cloudflare、Nuxt 服务端代理和 Docker 后,Koa 最终看到的还可能只是代理或容器地址。

这次开发让我重新梳理了一遍几个容易混在一起的概念:客户端 IP、HTTP 出口 IP、WebRTC 候选地址、服务端观察到的来源 IP,以及 IP 归属地并不是同一件事。

这篇文章记录最终的实现方式,以及这次排查里真正有用的判断方法。

最后采用的链路

指定 IP 查询比较直接:

Text
指定 IP
  ↓
Nuxt 页面
  ↓
Nuxt Server API
  ↓
Koa 服务
  ↓
ip2region xdb
  ↓(离线库无有效结果)
高德 IP 服务(仅作为国内 IPv4 补充)

“查询我的 IP”多了一层浏览器侧探测:

Text
浏览器
  ├─ HTTP 回显服务(ipify)
  └─ WebRTC / STUN 的 srflx candidate
          ↓
得到一条或多条公网路径地址
          ↓
交给后端查询归属地

这里最重要的一点是:WebRTC 只能帮助观察某条网络路径上的地址,它不负责地理定位,也不能证明某个地址就是所谓“真实 IP”。

IP 所在国家、省份、城市和运营商,仍然来自 IP 数据库或在线定位服务。

项目结构

我的博客拆成了几个独立仓库:

  • D:\Levi-Code\web:博客前端、实验室页面、Nuxt Server API。
  • D:\Levi-Code\service:Koa 后端,负责 IP 查询、天气、城市编码和访问分析。
  • D:\Levi-Code\admin:博客后台,展示访问分析数据。

和 IP 查询有关的代码主要在:

Text
web/app/pages/lab/ip.vue
web/server/api/[...path].ts
service/routers/tools.js
service/routers/analytics.js

这样拆的一个直接好处是,高德 Key 只存在服务端。浏览器只访问自己的 Nuxt/Koa 接口,不需要接触第三方服务密钥;公开 IP 查询和后台访问分析也可以复用同一套归属地解析逻辑。

“当前 IP”到底是哪一个 IP

最开始我直接在 Koa 里读取:

JavaScript
const ip = ctx.request.ip;

本地直连时没什么问题,但放进实际部署链路后,这个值就未必是用户地址了。

请求可能经过:

Text
浏览器
  ↓
Cloudflare
  ↓
Nginx
  ↓
Nuxt Server
  ↓
Koa
  ↓
Docker Network

Koa 最终拿到的 TCP 对端可能是 Nginx、Nuxt 容器或 Docker 网桥。另一方面,X-Forwarded-ForX-Real-IP 这类头也不能无条件相信,因为请求在到达可信代理之前,客户端就可以自己带上同名 Header。

所以我最后没有把“读某一个 Header”当成解决方案,而是先确定可信代理边界

Koa 只在可信代理后面读取转发头

我的处理思路是:

  1. 先读 TCP 连接的 remoteAddress
  2. 判断这个地址是不是我明确配置过的代理。
  3. 只有确认请求来自可信代理,才读取代理写入的客户端 IP Header。
  4. 所有候选值再次做 IP 格式校验。
  5. 如果代理链不确定,就退回 TCP 对端地址,不猜。

核心逻辑可以简化成:

JavaScript
function getClientIp(ctx) {
  const socketAddress = normalizedAddress(
    ctx.request.socket?.remoteAddress
  );

  const trusted = getTrustedProxyAddresses();

  if (socketAddress && trusted.has(socketAddress)) {
    const forwarded = forwardedClientIp(ctx);
    if (forwarded) return forwarded;
  }

  return socketAddress && net.isIP(socketAddress)
    ? socketAddress
    : '';
}

生产环境通过环境变量维护可信代理地址:

ENV
TRUSTED_PROXY_IPS=127.0.0.1,::1,::ffff:127.0.0.1

这只是示例,实际不能照抄。

如果 Koa 跑在 Docker 里,真正连接它的可能是容器网桥地址;如果前面还有 Nuxt Server、Nginx 或 Cloudflare,代理链也会继续变长。配置过窄,后端只能看到代理;配置过宽,又会扩大伪造客户端 IP 的风险。

Koa 本身也提供了 app.proxyproxyIpHeadermaxIpsCount。如果代理层级固定,可以直接利用这些能力限制 X-Forwarded-For 的读取范围。

官方文档:

X-Forwarded-For 不能只取第一项

如果存在多层代理,X-Forwarded-For 一般是这样的:

Text
client, proxy1, proxy2

最左侧看起来像客户端地址,但它也最容易被客户端提前伪造。

更稳妥的做法是从靠近服务端的一侧开始,根据自己的可信代理列表反向剥离代理节点,直到找到第一跳不可信地址,而不是无条件拿第一个值。

如果使用 Cloudflare,Cloudflare 官方建议源站优先使用 CF-Connecting-IPTrue-Client-IP(Enterprise),而不是自己猜 X-Forwarded-For。但前提仍然是:源站必须确认请求确实来自 Cloudflare 或自己控制的代理链,并且边缘代理要覆盖或清理外部传入的同名 Header。

参考:

ip2region:适合做第一层,但不要把它当实时定位

后端第一层使用 ip2region_v4.xdb

它很适合博客这种小工具:

  • 本地查询,不需要每次调用第三方接口;
  • 响应快;
  • 不会把每一次查询 IP 都发送给在线服务;
  • 没有按请求计费的问题。

但它毕竟是离线数据库。归属地会受数据库版本、运营商网络调整、IP 段重新分配和数据源更新影响。

所以页面上的“深圳”“California”“电信”都应该理解成当前数据源对这个 IP 段的归属判断,不是 GPS,也不是设备实时位置。

ip2region 目前同时提供 IPv4、IPv6 xdb,字段格式也已经包含国家、省份、城市、ISP 和国家代码。数据会随着项目版本更新,因此线上最好记录正在使用的 xdb 版本或文件哈希,方便后面定位“为什么同一个 IP 结果变了”。

官方仓库:

处理 0 和空字段

xdb 中有些字段会返回 0,页面直接展示会很奇怪,所以我统一转换为空字符串:

JavaScript
function regionValue(value) {
  const text = String(value || '').trim();
  return text && text !== '0' ? text : '';
}

最终页面显示“未知”或“数据源未提供”,而不是把 0 当成城市或运营商名称。

高德只能算补充数据源

离线库没有有效结果时,我会尝试调用高德:

JavaScript
async function lookupIp(ip, isMine = false) {
  const localData = await lookupIp2region(ip);
  if (localData) return { ip, isMine, ...localData };

  const data = await amapGet('/v3/ip', {
    ip,
    output: 'json',
  });

  if (!data) return null;
  return normalizeAmapIpData(data, ip, isMine);
}

GAODE_KEY 只放在 Koa 服务端,不下发到浏览器。

这里有一个容易忽略的限制:高德普通 /v3/ip 目前只支持 IPv4,而且不支持国外 IP 解析。

所以它不能被描述成“通用在线兜底”。更准确的说法是:

Text
ip2region
  ↓ 无结果
如果是国内 IPv4 → 尝试高德 v3/ip
如果是国外 IP / IPv6 → 当前没有高德普通接口兜底

高德另外提供高级 IP 定位 v5/ip/location,支持 IPv4、IPv6 和国外 IP,但它属于高级服务,需要单独申请。

官方文档:

另外,当前代码只要 ip2region 返回了国家或省份,就认为查询成功。这个判断还可以再细一点。

例如:

Text
国家:有
省份:有
城市:空
ISP:空

这种只能算“部分结果”。后续可以给结果做完整度评分,再决定是否请求第二数据源,而不是只看 localData 是否存在。

119 和 103:这次问题其实不在归属地数据库

这次排查里最容易误判的是两个地址:

Text
119.123.52.44
103.198.26.149

我当时在 IP138 查询:

Text
119.123.52.44 → 中国 / 广东省 / 深圳市 / 电信

但线上博客显示的是:

Text
103.198.26.149 → Australia / New South Wales / 城市未知

第一反应很容易是:是不是 ip2region 把深圳判断成澳大利亚了?

把两个 IP 分开测试后才发现,数据库对两个输入的结果都是稳定的,真正发生变化的是传给数据库的 IP 本身

还有一个当时忽略的小细节:我打开的 IP138 地址里已经带了查询参数:

Text
?ip=119.123.52.44

它只能证明 119.123.52.44 在那个数据源里的归属结果,不能证明访问博客的浏览器当时也通过这个地址出网。

这一步把排查方向从“数据库错了”拉回到了“IP 是怎么拿到的”。

为什么同一个浏览器会出现两个公网 IP

在 VPN、代理、加速器或规则分流环境里,不同协议不一定走同一条路径。

例如:

Text
HTTP / HTTPS 请求        → 103.198.26.149
WebRTC / STUN           → 119.123.52.44
服务端 TCP 对端          → 代理、CDN 或容器地址

我旧版的实现是先请求 ipify:

Text
ipify 成功 → 直接使用
ipify 失败 → 再尝试 WebRTC

这在普通网络里通常没问题,但只要代理软件对 HTTP 和 WebRTC 的处理规则不同,两条路径就可能得到不同地址。

所以问题不是“ipify 不准”,而是我把一个 HTTP 回显服务得到的地址,直接定义成了唯一的当前公网 IP。

这个定义本身就太绝对。

WebRTC 能告诉我什么

浏览器通过 RTCPeerConnection 收集 ICE candidate。srflx(server reflexive)candidate 是 STUN/TURN 服务器观察到的 NAT 映射地址。

我原来的实现直接从 candidate 字符串里匹配公网 IPv4:

TypeScript
const match = candidate.match(
  /\s((?:\d{1,3}\.){3}\d{1,3})\s+\d+\s+typ\s+srflx\b/
);

然后让 WebRTC 和 ipify 并行执行:

TypeScript
const [webRtcResult, ipifyResult] = await Promise.allSettled([
  resolveWebRtcPublicIpv4(),
  $fetch<{ ip?: string }>('https://api.ipify.org?format=json'),
]);

const webRtcIp = webRtcResult.status === 'fulfilled'
  ? webRtcResult.value
  : '';

const ipifyIp = ipifyResult.status === 'fulfilled'
  ? String(ipifyResult.value?.ip || '').trim()
  : '';

现代浏览器已经提供 RTCIceCandidate.typeRTCIceCandidate.address。兼容性允许时,直接读结构化字段会比解析 candidate 文本更清楚:

TypeScript
pc.addEventListener('icecandidate', (event) => {
  const candidate = event.candidate;
  if (!candidate || candidate.type !== 'srflx') return;

  const address = candidate.address;
  if (!address || !isPublicIpv4(address)) return;

  resolve(address);
});

必要时再把 candidate 字符串解析作为兼容回退。

MDN 对 srflx 的定义很明确:它表示 NAT 为当前 ICE 路径建立的映射地址。它不等于设备物理位置,也不保证一定是 VPN 地址。

在某些 VPN 环境里,WebRTC 甚至可能暴露一条没有经过 VPN 的网络路径。这也是为什么 WebRTC IP 一直有隐私讨论。

参考:

我现在不再把 WebRTC 叫“真实 IP”

针对我这次遇到的 VPN 场景,代码阶段性采用了:

TypeScript
const value = webRtcIp || ipifyIp;

它解决的是“我的当前网络环境里,WebRTC 路径更符合我想观察的那条出口”这个具体问题。

但它不是通用规则。

更合适的 UI 应该把来源直接写出来:

Text
HTTP 出口 IP:103.198.26.149
WebRTC/STUN IP:119.123.52.44

如果两者不同,再提示:

当前浏览器存在多条公网网络路径,可能与 VPN、代理或规则分流有关。

这样比偷偷选一个地址然后叫“真实 IP”更准确。

访问分析为什么不能照搬 WebRTC

IP 查询工具和访问分析是两件不同的事。

查询工具是用户主动点击,我可以在浏览器里做一次 WebRTC 探测,再把结果交给后端查询。

访问分析记录的则是:这次 HTTP 请求通过什么链路到达服务器。

如果把浏览器主动上报的 WebRTC IP 当成访问日志里的客户端地址,会有几个问题:

  • WebRTC 和 HTTP 不一定走同一条路径;
  • 客户端提交的数据可以被修改;
  • 用户没有执行探测时根本拿不到;
  • 会额外扩大网络信息的采集范围。

所以后台访问分析仍然只走服务端链路:

Text
HTTP 请求
  ↓
可信代理 / TCP 对端
  ↓
Koa getClientIp
  ↓
脱敏、哈希、归属地查询
  ↓
后台分析

后台表格没有必要直接显示完整原始 IP。我的处理是保存脱敏展示值、哈希、国家、省份、城市、ISP 和数据源,用于基本的访问统计和问题排查。

IP 地址本身还可能和访问时间、行为记录组合后形成可关联的用户标识,所以能不保存的查询历史就不保存,日志也尽量避免输出完整原始值。

归属地查询另外加了缓存,避免同一个 IP 在短时间内反复查库或调用在线服务。缓存必须有数量上限和过期时间,不能让进程里的 Map 一直增长。

接口层做了哪些限制

先校验输入

用户输入不会直接拼到第三方 URL。

后端先用 Node.js 的 net.isIP() 判断语法:

JavaScript
if (!net.isIP(ip)) {
  ctx.throw(400, 'Invalid IP');
}

net.isIP() 已经可以区分合法 IPv4、IPv6 和非法输入。IPv4 分段校验如果只是为了判断格式,通常没必要再重复写一套;真正需要单独处理的是地址类型和 CIDR 范围

Node.js 文档:

私有、回环和保留地址不发给在线服务

下面这些地址都不应该被当成普通公网 IP:

Text
127.0.0.1      # loopback
192.168.1.1    # private
10.0.0.1       # private
172.16.0.1     # private
169.254.1.1    # link-local
100.64.0.1     # carrier-grade NAT shared space

真实代码里不要只维护几个示例 IP,而应该按 CIDR 判断私有、回环、链路本地、共享地址、文档地址、组播和其他保留范围。

页面可以直接返回:

Text
该地址不是可用于公网归属地查询的普通公网 IP。

没必要浪费第三方接口配额,也避免把内部网络信息发送出去。

限流

IP 查询和天气查询分别做简单时间窗口限流。目前是单个来源每分钟最多 30 次,超出返回 429

这个数字只是当前博客流量下的配置,不是固定最佳值。后面如果公开使用量变大,再根据真实请求量调整。

第三方接口设置超时

在线查询设置 5 秒超时,并把网络错误、无结果和第三方服务异常统一转换成自己的接口错误。

浏览器不应该看到:

Text
GAODE_KEY
完整第三方请求 URL
服务器内部堆栈

不记录手工查询历史

用户在工具里输入什么 IP,我不写 localStorage,也不单独保存查询历史。

服务端缓存只用于短时间去重和降低查询成本,过期后自动丢弃。

测试时不要只看“页面有结果”

我这次排查最有用的办法,是把链路拆开测。

指定 IP 查询

Shell
curl -X POST "https://api.leviqin.top/api/tools/identifyIpArea" \
  -H "Content-Type: application/json" \
  --data '{"ip":"119.123.52.44"}'

如果当前固定版本的 xdb 里这个地址预期在深圳,那么响应可以检查:

JSON
{
  "ip": "119.123.52.44",
  "country": "中国",
  "province": "广东省",
  "city": "深圳市",
  "isp": "电信"
}

但这里有一个测试设计问题:IP 归属地数据库会更新,不能把外部可变数据永久写成绝对不变的契约。

更稳的做法是:

  • 契约测试使用固定 fixture 或固定版本的 xdb;
  • 集成测试记录当前 xdb 版本/哈希;
  • 升级 xdb 后主动跑一遍 snapshot diff;
  • 私有地址、非法输入、Header 信任规则这些稳定逻辑才做严格断言。

例如:

Text
固定 xdb 版本:119.123.52.44 → 深圳
非法输入:256.1.1.1 → 400
私有地址:192.168.1.1 → private/reserved
不可信代理:伪造 X-Forwarded-For 不得生效

如果每次更新 xdb 都要求 119.123.52.44 永远返回深圳,反而会把正常的数据更新当成程序回归。

“查询我的 IP”怎么查

打开浏览器 DevTools,重点看四件事:

  1. ipify 返回了什么地址;
  2. WebRTC 是否产生 srflx candidate;
  3. 最终发给 Koa 的请求体里是什么 IP;
  4. Koa 返回的 data.ip 是否和请求体一致。

判断顺序也很重要:

Text
页面显示 IP ≠ response.data.ip
→ 前端字段映射问题

request body 里的 IP 就错了
→ 浏览器 IP 获取路径问题

request body 正确,但归属地错了
→ 再查 ip2region / 在线数据源

这样能避免一上来就怀疑数据库。

我使用的测试地址

下面这些地址主要用来覆盖不同场景。归属地示例只对我当时使用的 xdb 版本有意义,数据库更新后允许变化。

IP 用途 / 当时结果
119.123.52.44 中国 / 广东省 / 深圳市 / 电信
103.198.26.149 Australia / New South Wales
8.8.8.8 公共 DNS,验证海外 IP
1.1.1.1 公共 DNS / Anycast,适合验证多数据源差异
114.114.114.114 国内公共 DNS
223.5.5.5 阿里公共 DNS

边界值:

Text
192.168.1.1 → 私有地址
127.0.0.1   → 回环地址
256.1.1.1   → 非法 IP
1.1.1       → 非法 IP

测试的时候,我现在主要看三个东西:

  • 后端实际查询的 IP 是不是输入的那个;
  • source 是否真的对应 ip2region / 高德;
  • 数据源没有城市或 ISP 时,页面有没有老老实实显示“未知”。

部署时最容易漏掉的几步

Nuxt 改完要重新构建

WebRTC 和 ipify 的选择逻辑最终会被打进前端产物。

只重启旧容器、不重新构建镜像,线上还是旧 JavaScript。

Shell
yarn build

Koa 镜像里必须带 xdb

Dockerfile 要确认包含:

Text
data/ip2region_v4.xdb

如果本地有文件、线上镜像没有,线上行为就会和本地完全不同:可能直接走在线接口,也可能返回空结果。

如果以后启用 IPv6,还要同时处理:

Text
data/ip2region_v6.xdb

环境变量分清职责

Text
GAODE_KEY                Koa 调用高德
TRUSTED_PROXY_IPS        Koa 的可信代理
NUXT_TRUSTED_PROXY_IPS   Nuxt Server 的可信代理
NUXT_API_BASE            Nuxt 到 Koa 的服务地址

这几项如果混在一起,最容易出现“本地正确、线上地址不对”的情况。

不要只清浏览器缓存

如果线上行为还是旧的,要确认:

  • 新镜像是否真的构建;
  • 容器是不是跑的新镜像;
  • Nuxt chunk hash 是否变化;
  • CDN 是否还缓存旧资源。

前后端一起验收

完整链路是:

Text
浏览器按钮
  → Nuxt 页面
  → Nuxt Server API
  → Koa
  → ip2region / 高德
  → 接口响应
  → 页面字段映射

任何一层都可能把正确结果变成错误结果,所以只测 Koa 接口还不够。

这次排查后,我改掉了几个想当然的判断

手工查询一个 IP,不等于查询“我的 IP”

?ip=119.123.52.44 的 IP138 页面,只说明这个指定 IP 的查询结果。

IP 回显服务只代表它看到的 HTTP 出口

ipify 没有“错”,它只是告诉我:请求到它那里时使用了哪个公网地址。

WebRTC 也不是“真实 IP 探测器”

它暴露的是 ICE 候选和网络路径信息。VPN、浏览器隐私策略、STUN 可达性都会影响结果。

X-Forwarded-For 不是可信身份字段

它只有放在明确的可信代理模型里才有意义。

IP 归属地不能当 GPS

它通常反映 IP 段登记、网络出口、运营商路由或数据库自己的映射结果,不能拿来推断精确地址,更不能据此确认一个人的身份。

后续准备继续做的几件事

同时展示多条公网路径

我更倾向于把页面改成:

Text
HTTP 出口 IP
WebRTC / STUN IP
服务器观察到的客户端地址

只要它们不同,就明确告诉用户当前网络存在代理、VPN、CDN 或协议分流,不再替用户选一个“最真实”的 IP。

给归属地结果增加质量等级

除了 source,再加一层:

Text
complete
partial
unknown
conflict

例如有国家、省份,但没有城市和 ISP,就标记成 partial

多数据源只做比对,不假装投票能得到真相

可以接入 MaxMind、IPinfo 或其他合规数据源做交叉验证,展示来源和更新时间。

如果两个数据库冲突,最合理的状态应该是“数据源不一致”,而不是随便挑一个当最终答案。

xdb 更新进入部署流程

升级 ip2region 时同时记录:

Text
xdb 版本
文件 SHA-256
更新时间
回归测试结果

这样线上归属地变化时,可以快速确认是代码变了,还是数据库变了。

补齐 IPv6

当前页面主要面向 IPv4。

后续要完整支持 IPv6,需要同时处理:

  • IPv6 输入;
  • ip2region_v6.xdb
  • IPv6 在线回退;
  • 压缩写法;
  • IPv4-mapped IPv6;
  • IPv6 隐私地址;
  • 代理链里的 IPv6 Header。

自动化测试拆成两类

稳定逻辑做严格契约测试:

Text
非法 IP → 400
私有 IP → 不调用在线服务
不可信 XFF → 不采纳
可信代理 → 正确提取客户端地址

归属地数据则做带数据库版本的集成测试,避免把会变化的真实世界数据写成永久常量。

写在最后

这个功能最后最难的并不是调用 IP API,而是把“IP”这个词说清楚。

同一个用户,在同一时刻可能同时存在:

Text
HTTP 请求出口
WebRTC / STUN 映射地址
CDN 看到的客户端地址
Nginx 看到的来源地址
Koa 的 TCP 对端地址

它们都可能是正确的,只是观察位置和网络路径不同。

所以现在这个工具遵循几个很简单的原则:

  1. 指定 IP 查询和“查询我的 IP”分开处理。
  2. WebRTC 只做网络路径探测,不负责地理定位。
  3. ip2region 做第一层离线查询,高德普通接口只补充国内 IPv4。
  4. 访问分析只信服务端可信代理链,不采纳浏览器自报 IP。
  5. 页面保留数据源和“未知”状态,不为了完整好看而补不存在的城市或 ISP。
  6. 出现归属地异常时,先确认实际查询的 IP,再查数据库。

把这几个边界理顺之后,IP 查询这个小工具才算真正可控。

参考资料

阅读进度 0%