最近给博客实验室加了一个 IP 查询工具。功能看起来不复杂:输入一个 IPv4,查国家、省份、城市和运营商;再放一个“查询我的 IP”按钮,看看当前浏览器通过哪条网络路径访问公网。
真正上线以后,问题很快就来了。
同一台电脑、同一个浏览器,我在不同地方看到的公网 IP 居然不一样。HTTP 请求是一个地址,WebRTC/STUN 又可能得到另一个地址;请求经过 Nginx、Cloudflare、Nuxt 服务端代理和 Docker 后,Koa 最终看到的还可能只是代理或容器地址。
这次开发让我重新梳理了一遍几个容易混在一起的概念:客户端 IP、HTTP 出口 IP、WebRTC 候选地址、服务端观察到的来源 IP,以及 IP 归属地并不是同一件事。
这篇文章记录最终的实现方式,以及这次排查里真正有用的判断方法。
最后采用的链路
指定 IP 查询比较直接:
指定 IP
↓
Nuxt 页面
↓
Nuxt Server API
↓
Koa 服务
↓
ip2region xdb
↓(离线库无有效结果)
高德 IP 服务(仅作为国内 IPv4 补充)
“查询我的 IP”多了一层浏览器侧探测:
浏览器
├─ 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 查询有关的代码主要在:
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 里读取:
const ip = ctx.request.ip;
本地直连时没什么问题,但放进实际部署链路后,这个值就未必是用户地址了。
请求可能经过:
浏览器
↓
Cloudflare
↓
Nginx
↓
Nuxt Server
↓
Koa
↓
Docker Network
Koa 最终拿到的 TCP 对端可能是 Nginx、Nuxt 容器或 Docker 网桥。另一方面,X-Forwarded-For、X-Real-IP 这类头也不能无条件相信,因为请求在到达可信代理之前,客户端就可以自己带上同名 Header。
所以我最后没有把“读某一个 Header”当成解决方案,而是先确定可信代理边界。
Koa 只在可信代理后面读取转发头
我的处理思路是:
- 先读 TCP 连接的
remoteAddress。 - 判断这个地址是不是我明确配置过的代理。
- 只有确认请求来自可信代理,才读取代理写入的客户端 IP Header。
- 所有候选值再次做 IP 格式校验。
- 如果代理链不确定,就退回 TCP 对端地址,不猜。
核心逻辑可以简化成:
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
: '';
}
生产环境通过环境变量维护可信代理地址:
TRUSTED_PROXY_IPS=127.0.0.1,::1,::ffff:127.0.0.1
这只是示例,实际不能照抄。
如果 Koa 跑在 Docker 里,真正连接它的可能是容器网桥地址;如果前面还有 Nuxt Server、Nginx 或 Cloudflare,代理链也会继续变长。配置过窄,后端只能看到代理;配置过宽,又会扩大伪造客户端 IP 的风险。
Koa 本身也提供了 app.proxy、proxyIpHeader 和 maxIpsCount。如果代理层级固定,可以直接利用这些能力限制 X-Forwarded-For 的读取范围。
官方文档:
X-Forwarded-For 不能只取第一项
如果存在多层代理,X-Forwarded-For 一般是这样的:
client, proxy1, proxy2
最左侧看起来像客户端地址,但它也最容易被客户端提前伪造。
更稳妥的做法是从靠近服务端的一侧开始,根据自己的可信代理列表反向剥离代理节点,直到找到第一跳不可信地址,而不是无条件拿第一个值。
如果使用 Cloudflare,Cloudflare 官方建议源站优先使用 CF-Connecting-IP 或 True-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,页面直接展示会很奇怪,所以我统一转换为空字符串:
function regionValue(value) {
const text = String(value || '').trim();
return text && text !== '0' ? text : '';
}
最终页面显示“未知”或“数据源未提供”,而不是把 0 当成城市或运营商名称。
高德只能算补充数据源
离线库没有有效结果时,我会尝试调用高德:
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 解析。
所以它不能被描述成“通用在线兜底”。更准确的说法是:
ip2region
↓ 无结果
如果是国内 IPv4 → 尝试高德 v3/ip
如果是国外 IP / IPv6 → 当前没有高德普通接口兜底
高德另外提供高级 IP 定位 v5/ip/location,支持 IPv4、IPv6 和国外 IP,但它属于高级服务,需要单独申请。
官方文档:
另外,当前代码只要 ip2region 返回了国家或省份,就认为查询成功。这个判断还可以再细一点。
例如:
国家:有
省份:有
城市:空
ISP:空
这种只能算“部分结果”。后续可以给结果做完整度评分,再决定是否请求第二数据源,而不是只看 localData 是否存在。
119 和 103:这次问题其实不在归属地数据库
这次排查里最容易误判的是两个地址:
119.123.52.44
103.198.26.149
我当时在 IP138 查询:
119.123.52.44 → 中国 / 广东省 / 深圳市 / 电信
但线上博客显示的是:
103.198.26.149 → Australia / New South Wales / 城市未知
第一反应很容易是:是不是 ip2region 把深圳判断成澳大利亚了?
把两个 IP 分开测试后才发现,数据库对两个输入的结果都是稳定的,真正发生变化的是传给数据库的 IP 本身。
还有一个当时忽略的小细节:我打开的 IP138 地址里已经带了查询参数:
?ip=119.123.52.44
它只能证明 119.123.52.44 在那个数据源里的归属结果,不能证明访问博客的浏览器当时也通过这个地址出网。
这一步把排查方向从“数据库错了”拉回到了“IP 是怎么拿到的”。
为什么同一个浏览器会出现两个公网 IP
在 VPN、代理、加速器或规则分流环境里,不同协议不一定走同一条路径。
例如:
HTTP / HTTPS 请求 → 103.198.26.149
WebRTC / STUN → 119.123.52.44
服务端 TCP 对端 → 代理、CDN 或容器地址
我旧版的实现是先请求 ipify:
ipify 成功 → 直接使用
ipify 失败 → 再尝试 WebRTC
这在普通网络里通常没问题,但只要代理软件对 HTTP 和 WebRTC 的处理规则不同,两条路径就可能得到不同地址。
所以问题不是“ipify 不准”,而是我把一个 HTTP 回显服务得到的地址,直接定义成了唯一的当前公网 IP。
这个定义本身就太绝对。
WebRTC 能告诉我什么
浏览器通过 RTCPeerConnection 收集 ICE candidate。srflx(server reflexive)candidate 是 STUN/TURN 服务器观察到的 NAT 映射地址。
我原来的实现直接从 candidate 字符串里匹配公网 IPv4:
const match = candidate.match(
/\s((?:\d{1,3}\.){3}\d{1,3})\s+\d+\s+typ\s+srflx\b/
);
然后让 WebRTC 和 ipify 并行执行:
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.type 和 RTCIceCandidate.address。兼容性允许时,直接读结构化字段会比解析 candidate 文本更清楚:
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 场景,代码阶段性采用了:
const value = webRtcIp || ipifyIp;
它解决的是“我的当前网络环境里,WebRTC 路径更符合我想观察的那条出口”这个具体问题。
但它不是通用规则。
更合适的 UI 应该把来源直接写出来:
HTTP 出口 IP:103.198.26.149
WebRTC/STUN IP:119.123.52.44
如果两者不同,再提示:
当前浏览器存在多条公网网络路径,可能与 VPN、代理或规则分流有关。
这样比偷偷选一个地址然后叫“真实 IP”更准确。
访问分析为什么不能照搬 WebRTC
IP 查询工具和访问分析是两件不同的事。
查询工具是用户主动点击,我可以在浏览器里做一次 WebRTC 探测,再把结果交给后端查询。
访问分析记录的则是:这次 HTTP 请求通过什么链路到达服务器。
如果把浏览器主动上报的 WebRTC IP 当成访问日志里的客户端地址,会有几个问题:
- WebRTC 和 HTTP 不一定走同一条路径;
- 客户端提交的数据可以被修改;
- 用户没有执行探测时根本拿不到;
- 会额外扩大网络信息的采集范围。
所以后台访问分析仍然只走服务端链路:
HTTP 请求
↓
可信代理 / TCP 对端
↓
Koa getClientIp
↓
脱敏、哈希、归属地查询
↓
后台分析
后台表格没有必要直接显示完整原始 IP。我的处理是保存脱敏展示值、哈希、国家、省份、城市、ISP 和数据源,用于基本的访问统计和问题排查。
IP 地址本身还可能和访问时间、行为记录组合后形成可关联的用户标识,所以能不保存的查询历史就不保存,日志也尽量避免输出完整原始值。
归属地查询另外加了缓存,避免同一个 IP 在短时间内反复查库或调用在线服务。缓存必须有数量上限和过期时间,不能让进程里的 Map 一直增长。
接口层做了哪些限制
先校验输入
用户输入不会直接拼到第三方 URL。
后端先用 Node.js 的 net.isIP() 判断语法:
if (!net.isIP(ip)) {
ctx.throw(400, 'Invalid IP');
}
net.isIP() 已经可以区分合法 IPv4、IPv6 和非法输入。IPv4 分段校验如果只是为了判断格式,通常没必要再重复写一套;真正需要单独处理的是地址类型和 CIDR 范围。
Node.js 文档:
私有、回环和保留地址不发给在线服务
下面这些地址都不应该被当成普通公网 IP:
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 判断私有、回环、链路本地、共享地址、文档地址、组播和其他保留范围。
页面可以直接返回:
该地址不是可用于公网归属地查询的普通公网 IP。
没必要浪费第三方接口配额,也避免把内部网络信息发送出去。
限流
IP 查询和天气查询分别做简单时间窗口限流。目前是单个来源每分钟最多 30 次,超出返回 429。
这个数字只是当前博客流量下的配置,不是固定最佳值。后面如果公开使用量变大,再根据真实请求量调整。
第三方接口设置超时
在线查询设置 5 秒超时,并把网络错误、无结果和第三方服务异常统一转换成自己的接口错误。
浏览器不应该看到:
GAODE_KEY
完整第三方请求 URL
服务器内部堆栈
不记录手工查询历史
用户在工具里输入什么 IP,我不写 localStorage,也不单独保存查询历史。
服务端缓存只用于短时间去重和降低查询成本,过期后自动丢弃。
测试时不要只看“页面有结果”
我这次排查最有用的办法,是把链路拆开测。
指定 IP 查询
curl -X POST "https://api.leviqin.top/api/tools/identifyIpArea" \
-H "Content-Type: application/json" \
--data '{"ip":"119.123.52.44"}'
如果当前固定版本的 xdb 里这个地址预期在深圳,那么响应可以检查:
{
"ip": "119.123.52.44",
"country": "中国",
"province": "广东省",
"city": "深圳市",
"isp": "电信"
}
但这里有一个测试设计问题:IP 归属地数据库会更新,不能把外部可变数据永久写成绝对不变的契约。
更稳的做法是:
- 契约测试使用固定 fixture 或固定版本的 xdb;
- 集成测试记录当前 xdb 版本/哈希;
- 升级 xdb 后主动跑一遍 snapshot diff;
- 私有地址、非法输入、Header 信任规则这些稳定逻辑才做严格断言。
例如:
固定 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,重点看四件事:
- ipify 返回了什么地址;
- WebRTC 是否产生
srflxcandidate; - 最终发给 Koa 的请求体里是什么 IP;
- Koa 返回的
data.ip是否和请求体一致。
判断顺序也很重要:
页面显示 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 |
边界值:
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。
yarn build
Koa 镜像里必须带 xdb
Dockerfile 要确认包含:
data/ip2region_v4.xdb
如果本地有文件、线上镜像没有,线上行为就会和本地完全不同:可能直接走在线接口,也可能返回空结果。
如果以后启用 IPv6,还要同时处理:
data/ip2region_v6.xdb
环境变量分清职责
GAODE_KEY Koa 调用高德
TRUSTED_PROXY_IPS Koa 的可信代理
NUXT_TRUSTED_PROXY_IPS Nuxt Server 的可信代理
NUXT_API_BASE Nuxt 到 Koa 的服务地址
这几项如果混在一起,最容易出现“本地正确、线上地址不对”的情况。
不要只清浏览器缓存
如果线上行为还是旧的,要确认:
- 新镜像是否真的构建;
- 容器是不是跑的新镜像;
- Nuxt chunk hash 是否变化;
- CDN 是否还缓存旧资源。
前后端一起验收
完整链路是:
浏览器按钮
→ 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 段登记、网络出口、运营商路由或数据库自己的映射结果,不能拿来推断精确地址,更不能据此确认一个人的身份。
后续准备继续做的几件事
同时展示多条公网路径
我更倾向于把页面改成:
HTTP 出口 IP
WebRTC / STUN IP
服务器观察到的客户端地址
只要它们不同,就明确告诉用户当前网络存在代理、VPN、CDN 或协议分流,不再替用户选一个“最真实”的 IP。
给归属地结果增加质量等级
除了 source,再加一层:
complete
partial
unknown
conflict
例如有国家、省份,但没有城市和 ISP,就标记成 partial。
多数据源只做比对,不假装投票能得到真相
可以接入 MaxMind、IPinfo 或其他合规数据源做交叉验证,展示来源和更新时间。
如果两个数据库冲突,最合理的状态应该是“数据源不一致”,而不是随便挑一个当最终答案。
xdb 更新进入部署流程
升级 ip2region 时同时记录:
xdb 版本
文件 SHA-256
更新时间
回归测试结果
这样线上归属地变化时,可以快速确认是代码变了,还是数据库变了。
补齐 IPv6
当前页面主要面向 IPv4。
后续要完整支持 IPv6,需要同时处理:
- IPv6 输入;
ip2region_v6.xdb;- IPv6 在线回退;
- 压缩写法;
- IPv4-mapped IPv6;
- IPv6 隐私地址;
- 代理链里的 IPv6 Header。
自动化测试拆成两类
稳定逻辑做严格契约测试:
非法 IP → 400
私有 IP → 不调用在线服务
不可信 XFF → 不采纳
可信代理 → 正确提取客户端地址
归属地数据则做带数据库版本的集成测试,避免把会变化的真实世界数据写成永久常量。
写在最后
这个功能最后最难的并不是调用 IP API,而是把“IP”这个词说清楚。
同一个用户,在同一时刻可能同时存在:
HTTP 请求出口
WebRTC / STUN 映射地址
CDN 看到的客户端地址
Nginx 看到的来源地址
Koa 的 TCP 对端地址
它们都可能是正确的,只是观察位置和网络路径不同。
所以现在这个工具遵循几个很简单的原则:
- 指定 IP 查询和“查询我的 IP”分开处理。
- WebRTC 只做网络路径探测,不负责地理定位。
- ip2region 做第一层离线查询,高德普通接口只补充国内 IPv4。
- 访问分析只信服务端可信代理链,不采纳浏览器自报 IP。
- 页面保留数据源和“未知”状态,不为了完整好看而补不存在的城市或 ISP。
- 出现归属地异常时,先确认实际查询的 IP,再查数据库。
把这几个边界理顺之后,IP 查询这个小工具才算真正可控。
参考资料
- Koa:https://koajs.com/
- Node.js
net.isIP():https://nodejs.org/api/net.html#netisipinput - ip2region:https://github.com/lionsoul2014/ip2region
- Cloudflare HTTP Headers:https://developers.cloudflare.com/fundamentals/reference/http-headers/
- 高德 IP 定位:https://lbs.amap.com/api/webservice/guide/api/ipconfig
- 高德高级 IP 定位:https://lbs.amap.com/api/webservice/guide/api-advanced/ip
- MDN
RTCIceCandidate.type:https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type - MDN WebRTC Connectivity:https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Connectivity
- W3C WebRTC Privacy and Security:https://www.w3.org/TR/webrtc/#privacy-security
private note
交流
文章暂不开放公开评论。如果你有想法、问题或建议,欢迎私下联系站长。
联系站长 →