QCE 插件 Token 认证失败排查实录
NapCat Docker 容器 + Nginx 反向代理环境下,QQ Chat Exporter 插件始终返回「无效的访问令牌」的根因分析与解决方案
简介
在 NapCat Docker 容器中通过插件商店安装 QQ Chat Exporter(QCE)插件后,无论通过何种网络路径访问,POST /auth 始终返回 {"success":false,"error":{"message":"无效的访问令牌"}},导致无法登录使用。本文按时间顺序记录了完整的排查过程、根因分析和解决方案。
宿主机系统: Debian 13(内核 6.12.85+deb13-amd64)
Docker 版本: 29.4.3(build 055a478)
NapCat Docker: WebUI v0.0.6 / Core 4.18.1
QQ Chat Exporter(QCE): v5.5.62
反向代理: OpenResty 1.27.1.2-5-1-focal(基于 Nginx)
Linux QQ:3.2.28-48517
复现条件:在 Docker 条件下安装 NapCat ,然后从 webUI 中的插件商店下载 QCE,在 Docker 中开放 127.0.0.1:40653 端口,使用 Nginx 开启域名反代。
一、初始搭建
首先,将 NapCat Docker 容器端口 40653 映射到宿主机 127.0.0.1:40653。
随后,在服务器 SSH 控制台输入以下命令获取 Token:
docker logs napcat 2>&1 | grep -i "token"在本地电脑运行以下 SSH 命令:
ssh -L 40653:127.0.0.1:40653 user@服务器IP打开 http://127.0.0.1:40653/qce-v4-tool,输入容器日志中打印的 Token。
结果宣告失败:「无效的访问令牌」。
于是我立即检查了端口映射:
docker port [NapCat_Docker_ID]| grep 40653
40653/tcp -> 127.0.0.1:40653同样,把端口映射到 0.0.0.0:40653,通过公网或打洞访问,结果也是一样。
容器内服务明明在运行,端口也正常监听,Token 也绝无输错的可能,但就是无法通过认证。这个“容器外任何访问都失败”的现象,成了贯穿始终的谜题。
二、盲目排查,多轮尝试
面对“容器外任何访问都失败”的谜题,我开始了多轮尝试。
尝试 1:检查 Token 是否正确
最直接的怀疑:Token 是不是输错了?
因为他们都报出了同一个错误:「无效的访问令牌」。
我反复对比了日志中的 Token 和浏览器输入的值,确认每一个字符都完全一致。
docker logs napcat 2>&1 | grep -i "token" Token 和输入的完全一致。排除 Token 输入错误。
尝试 2:直接进入容器内部访问
docker exec napcat curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"正确的Token"}'结果:"message":"认证成功"
容器内认证成功,这个发现证明了服务本身是正常的,Token 也没问题。问题一定出在“请求从容器外进入容器”的这个过程里。容器内能过,容器外全挂,意味着请求在跨越 Docker 边界时,某个条件发生了变化。
尝试 3:宿主机直接访问
既然容器内正常,下一步就是测试宿主机本身的本地端口映射(即 127.0.0.1:40653 )。
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"正确的Token"}' 结果:"message":"无效的访问令牌"
宿主机用 127.0.0.1 直连容器端口,居然也失败了。这说明问题出在宿主机到容器这一跳。
小结
| 测试场景 | 结果 |
|---|---|
容器内直连 127.0.0.1:40653 | 通过 |
| 宿主机经端口映射访问 | 拒绝 |
| SSH 打洞访问 | 拒绝 |
| 公网直连 | 拒绝 |
问题已经精准定位到 Docker 网络层:请求进入容器时,源 IP 被 Docker 替换,而后端对源 IP 有限制。要确认这个推断,必须深入源码。
三、灵光一闪
上面几轮测试下来,我已经有点烦躁了。
Token 没错。服务没挂。容器内自己访问自己,秒过。
但只要请求是从容器外面进来的(不管是从宿主机、SSH 打洞、还是公网),插件都抛出了相同的错误:「无效的访问令牌」。
这也太邪门了。
我梳理了一下刚刚的测试结果:
| 场景 | 访问的 IP | 结果 |
|---|---|---|
| 容器内直连 | NapCat Docker:127.0.0.1 | 200 |
| 宿主机端口映射 | 服务器宿主机回环 IP:127.0.0.1 | 403 |
| SSH 打洞 | 服务器宿主机回环 IP:127.0.0.1 | 403 |
| 公网直连 | 服务器宿主机公网 IP | 403 |
我盯着表格反复看了一会,突然注意到一个规律:唯一成功的访问,是在容器里面自己访问自己,没走端口映射,而其他失败的请求,都经过了端口映射。
我立即查阅了 Linux 服务器上 Docker 容器的网络请求路径:
外部发起访问(访问服务器公网 IP)
→ 宿主机物理网卡
→ iptables DNAT(目标地址转换,将宿主机公网 [IP:端口] 改为容器 [IP:端口])
→ Docker 网桥(在此处同时进行 SNAT,将源 IP 替换为网桥网关地址)
→ 容器虚拟网卡(veth)
→ 容器内进程容器内进程
→ 容器虚拟网卡(veth,容器端的虚拟网卡)
→ Docker 网桥(docker0,虚拟交换机)
→ iptables SNAT(源地址转换,将容器私有 IP 改为宿主机物理网卡的公网 IP)
→ 宿主机物理网卡(例如 eth0)
→ 外部接收访问DNAT
Destination Network Address Translation,目标地址转换。决定“这个请求最终要送到哪个地址”。
SNAT
Source Network Address Translation,源地址转换。决定“应用看到的请求是从哪个地址来的”。
Docker 的端口 SNAT 映射……等等,它好像不是透明转发?
Docker 的 -p 参数在转发流量时,会进行 SNAT 的操作,把原始请求的源 IP 替换成 Docker 网桥的网关地址,比如 172.17.0.1。
也就是说:
- 我在宿主机用
127.0.0.1发起请求 - 请求经过端口映射进入容器
- 容器里的服务看到的客户端 IP 根本不是
127.0.0.1,而是172.x.x.x
于是我有了一个大胆的猜测:QCE 插件的后端是不是对客户端 IP 做了限制?比如只允许某些 IP 访问?如果它默认只认 127.0.0.1,那所有被 DNAT 换过 IP 的请求当然全挂。
若假设如果成立,就能一次性解释所有现象。
但是我不确定 QCE 到底有没有做 IP 校验,如果做了,白名单又写在哪。
因此我需要验证这个想法。
恰好 QCE 是 GitHub 上的开源项目。我立刻去下载了源码。
QQ-Chat-Exporter:https://github.com/shuakami/qq-chat-exporter
四、向源码要答案
假设有了,但需要证据。
我重新梳理了一遍思路:容器内直连 127.0.0.1 能通过,但任何经过 Docker 端口映射的请求都失败。唯一的变量就是请求到达容器后,服务端看到的客户端 IP 不同。
如果 QCE 真的对 IP 做了限制:它把规则写在哪了?配置文件?数据库?还是写死在代码里?
我直接把源码拉了下来。老实说,我根本看不懂 TypeScript,面对一堆 .ts 文件,根本不知道该从哪里读起。
1. 让 AI 帮我读源码
我把 SecurityManager.ts、ApiServer.ts 这几个看起来跟认证相关的文件扔给了 AI,让它帮我梳理 POST /auth 的完整处理链。
AI 很快给出了分析结果。认证的核心链路是这样的:/auth 路由在 publicRoutes 列表里,全局中间件对它直接放行;403 只有一个来源,就是 verifyToken() 方法返回 false。
而 verifyToken 做了三重检查:
| 项目 | 输出 |
|---|---|
| Token 字符串精确匹配 | 不匹配就拒绝 |
| IP 白名单检查 | 如果白名单开着,且客户端 IP 不在 allowedIPs 列表里,拒绝 |
| Token 过期检查 | 超过 7 天就拒绝 |
看到第二点「IP 白名单检查」时,我脑子里只有一个声音:赌对了。
QCE 确实在做 IP 校验。
AI 接着告诉我,这个白名单的配置文件叫 security.json,路径在 /app/.qq-chat-exporter/。它在容器首次启动时自动生成。
同时,AI 还告诉我一个重要的设计细节:
生成配置时,程序会检测当前是否在 Docker 环境。如果是 Docker,disableIPWhitelist 会被设为 true,且 allowedIPs 会自动追加 172.16.0.0/12 等 Docker 网段。
但如果 security.json 已经存在了,程序就不再检测环境,直接读取文件内容。
也就是说,即使当前运行在 Docker 里,也不会更新白名单配置。
只要这个文件被持久化过、手动改过、从别处复制过来、或者它一直就在那里,Docker 环境的自动适配就彻底失效了。
后来我查了一下,这个行为其实有迹可循。GitHub lssue #162 号修复,报告的正是 Docker 桥接网络下 IP 白名单问题,作者在 v4.10.3 中通过 generateInitialConfig() 的 Docker 环境检测来修复。但这个修复有一个前提:security.json 不存在。通过插件商店安装时,security.json 随插件包一同打包发布,文件在首次启动前就已经在了,generateInitialConfig() 永远不会被调用,#162 的修复对这条安装路径完全失效。
2. 亲眼看看这份配置
我在容器里敲了一条命令:
docker exec napcat cat /app/.qq-chat-exporter/security.json控制台随即打印出:
{
"accessToken": "你的token",
"secretKey": "你的key",
"createdAt": "生成时间戳",
"allowedIPs":
[
"127.0.0.1",
"::1"
],
"tokenExpired": "token失效时间戳",
"disableIPWhitelist": false,
"lastAccess": "token最后一次被使用的时间戳"
}allowedIPs 里只有 127.0.0.1 和 ::1。
disableIPWhitelist 是 false,也就是白名单开着。
而且没有 172.16.0.0/12 等 Docker 网段。
所有线索在这一刻拼起来了。
五、代码是怎么做的
1. POST /auth 处理
POST /auth 的完整处理链如下:
3. security.json
{
"accessToken": "你的token",
"secretKey": "你的key",
"createdAt": "生成时间戳",
"allowedIPs": [
"127.0.0.1",
"::1"
],
"tokenExpired": "token失效时间戳",
"disableIPWhitelist": false,
"lastAccess": "token最后一次被使用的时间戳"
}disableIPWhitelist: false 。IP 白名单开启,且 allowedIPs 仅允许 127.0.0.1 和 ::1。
Docker 端口映射会做 SNAT,从宿主机进入的请求,容器看到的源 IP 是 Docker 网桥 IP(如 172.17.0.1),该 IP 不在白名单中,因此直接返回 403。
2. 白名单匹配
allowedIPs 里写的是 ["127.0.0.1", "::1"]。checkIPAllowed 对 clientIP 和列表逐条做匹配,支持精确 IP、CIDR 网段、以及 0.0.0.0 通配符。
但 clientIP 在容器外请求的场景下是 172.17.0.1:它不等于 127.0.0.1,不等于 ::1,也不在任何 CIDR 网段内。遍历完整个 allowedIPs 数组。
所以程序判定匹配数 = 零。
checkIPAllowed 返回 false → verifyToken 返回 false → 路由处理器返回 403。
4. 为什么没适配 Docker
QCE 的开发者不可能不知道 Docker 的存在,难道没有做环境适配?
做了,但是做了很“取巧”的判定:只在首次生成配置时生效。
SecurityManager 的 initialize() 方法分两条路径:
async initialize(): Promise<void> {
if (fs.existsSync(this.configPath)) {
await this.loadConfig(); // 文件存在 → 直接读取
} else {
await this.generateInitialConfig(); // 文件不存在 → 检测环境并生成
}
}generateInitialConfig 里有一段检测逻辑:
const defaultAllowedIPs = ['127.0.0.1', '::1'];
if (this.isDocker) {
defaultAllowedIPs.push('172.16.0.0/12');
defaultAllowedIPs.push('192.168.0.0/16');
defaultAllowedIPs.push('10.0.0.0/8');
}
this.config = {
// ...
disableIPWhitelist: this.isDocker
};如果是首次启动且检测到 Docker 环境,生成的文件里 disableIPWhitelist 会是 true,且 allowedIPs 会包含 Docker 私有网段。
但 security.json 一旦存在(持久化卷、手动创建、从其他环境复制、或者它本来就在那里),loadConfig 就直接读文件内容,不会重新检测 Docker 环境,更不会更新已有的白名单配置。
5. 这个 bug 是怎么被触发的
为什么我的 security.json 从一开始就是“已存在”的状态?
答案藏在 QCE 的分发方式里。
想要触发这个bug,QCE 插件必须是通过 NapCat 自带的插件商店安装的,而且必须是使用 Docker 部署的 NapCat。
插件包在打包时就已经内置了一份 security.json 文件,随着安装过程直接落盘到容器的 /app/.qq-chat-exporter/ 目录下。
换句话说,security.json 天生就存在。
当 QCE 启动、SecurityManager 初始化时,fs.existsSync(this.configPath) 返回的是 true。程序毫不犹豫地走进了 loadConfig 分支,读取了这份预置配置。
而这份预置配置,写的可是 disableIPWhitelist=false,且 allowedIPs 只有 127.0.0.1, ::1。
generateInitialConfig 里的 Docker 检测逻辑根本没有运行。
这个 bug 没有写在代码的字面上。它藏在 NapCat 插件商店的分发方式、Docker 网络层的默认行为、和 loadConfig 的一个设计取舍这三者的交叉点上。每个环节单独看都没有错,插件预设安全配置是合理的,Docker 做 SNAT 是合理的,文件已存在就直接读也是合理的。
但当它们组合在一起时,构成了一个谁也想不到的陷阱。
六、针对 security.json 的测试
阶段一:security.json 未修改
1.容器内访问
docker exec napcat curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的Token"}'结果:"message":"认证成功"
2.宿主机通过端口映射访问
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的Token"}'结果:"message":"无效的访问令牌"
阶段二:修改 security.json 后
1.关闭 IP 白名单
docker exec napcat sed -i \
's/"disableIPWhitelist": false/"disableIPWhitelist": true/' \
/app/.qq-chat-exporter/security.json2.容器内访问
docker exec napcat curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的Token"}'结果:"message":"认证成功"
3.宿主机访问
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的Token"}'结果:"message":"认证成功"
Docker SNAT 导致宿主机请求的 clientIP 变为 172.x.x.x,被 IP 白名单拦截。
关闭 IP 白名单后,宿主机请求成功,证明 IP 白名单问题已解决。
阶段三: Nginx 反代
如果故事到这里就结束,那这篇排查实录也就没资格称为“实录”了。
若此时我带着胜利的曙光通过 Nginx 完成了反代,并使用浏览器访问 https://你的域名 并登录,仍然显示 「无效的访问令牌」。
七、第二道坎
security.json 改完、宿主机 curl 通了,我心想这事总算收工了。白名单一关,谁拦得住?
我把 Nginx 重新挂好(带 X-Real-IP 和 X-Forwarded-For 的标准反代写法),浏览器里敲下 https://你的域名,输入 Token,回车。
无效的访问令牌。
两次请求唯一的区别就是中间隔了一个 Nginx。
这说明 IP 白名单只是第一道坎。Nginx 反代本身引入了另一个完全独立的干扰变量,不在 Docker 网络层(因为前面已经把白名单给关了),肯定藏在 Nginx 转发时附加的那些 HTTP 头里。
接下来要做的事就很明确了:把 Nginx 加的那些头一个一个拆开,看看究竟是哪个字段在搞鬼。
逐个 Header 一一排查
我先用 OpenResty 的 access_by_lua_block 在 Nginx 层把转发出去的请求 body 抓出来看了一眼。Token 完整无误,没有被截断、没有被编码搞坏。body 没问题,那就是 header 的问题。
接下来的思路很简单:在宿主机上直接用 curl 打容器端口,但手动把 Nginx 通常会附加的 header 一个一个加上去,看哪个触发了 403。
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-H "Host: 你的域名" \
-H "X-Real-IP: 服务器宿主机公网 IP" \
-H "X-Forwarded-For: 服务器宿主机公网 IP" \
-H "X-Forwarded-Proto: https" \
-d '{"token":"正确的Token"}'结果:"message":"无效的访问令牌"
预料之中,这基本就是 Nginx 转发的完整头集合。
然后开始做减法。
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-H "Host: 你的域名" \
-H "X-Forwarded-For: 服务器宿主机公网 IP" \
-H "X-Forwarded-Proto: https" \
-d '{"token":"正确的Token"}'结果:"message":"认证成功"
Host 还在,X-Forwarded-For 还在,X-Forwarded-Proto 还在。
把 X-Real-IP 拿掉,认证就过了。把它加回去,立刻 403。
再补两组对照确认:
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-H "X-Real-IP: 服务器宿主机公网 IP" \
-d '{"token":"正确的Token"}'结果:"message":"无效的访问令牌"
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-H "Host: 你的域名" \
-H "X-Forwarded-For: 服务器宿主机公网 IP" \
-H "X-Forwarded-Proto: https" \
-d '{"token":"正确的Token"}'结果:"message":"认证成功"
四组测试跑完,结论没有任何模糊空间:当 IP 白名单开启时,X-Real-IP / X-Forwarded-For 会改变 QCE 用于校验的 clientIP。如果该 IP 不在 allowedIPs 中,即使 Token 正确,也会被统一报为「无效的访问令牌」。哪怕白名单已经关了,哪怕 X-Forwarded-For 也传了同样的 IP 。
按源码优先级应该先读取 X-Forwarded-For,但是根本没用。X-Real-IP 里的公网地址就是一根引线,碰到就炸。
锁定第二层原因
X-Real-IP 携带公网 IP 是导致认证失败的直接原因。当 IP 白名单开启时,X-Real-IP 携带的公网 IP 会被 QCE 拿去做白名单校验;若该 IP 不在 allowedIPs 中,即使 Token 正确也会被拒。至于关闭白名单后是否仍受 X-Real-IP 影响,本文不作确定结论。
Docker SNAT 的坑找到了,security.json 也改了;宿主机直连成功,模拟 Nginx 请求成功,通过域名 curl 也成功。
一切证据都指向:问题已经解决。
把 Nginx 配置改好(基于 OpenResty 构建):
server {
listen 443 ssl ;
listen 443 quic ;
listen [::]:443 ssl ;
listen [::]:443 quic ;
server_name 你的域名;
access_log /www/sites/你的域名/log/access.log main;
error_log /www/sites/你的域名/log/error.log;
location / {
proxy_pass http://127.0.0.1:40653;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 开启 X-Real-IP
proxy_set_header X-Real-IP $remote_addr; # 传递真实客户端 IP
proxy_set_header X-Forwarded-For ""; # 保持清空,避免干扰
proxy_set_header X-Forwarded-Proto $scheme;
}
http2 on;
if ($scheme = http) {
return 301 https://$host$request_uri;
}
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ciphers ECDH...;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
error_page 497 https://$host$request_uri;
proxy_set_header X-Forwarded-Proto https;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
add_header Alt-Svc 'h3=":443"; ma=2592000';
ssl_certificate /www/sites/你的域名/ssl/fullchain.pem;
ssl_certificate_key /www/sites/你的域名/ssl/privkey.pem;
real_ip_recursive on;
set_real_ip_from 127.0.0.1;
real_ip_header X-Real-IP;
}我满心欢喜的在浏览器里敲下 https://你的域名,输入 Token,回车。
认证成功。
八、牛鬼蛇神
为了安全起见,我鬼使神差地又把 IP 白名单打开了,只把 Docker 网桥(172.16.0.0/12)和我的服务器宿主机公网 IP 写进了 allowedIPs。
再一次在浏览器里敲下 https://你的域名,输入 Token,回车。
「无效的访问令牌」。
“邪门了!”
1. 重测
我盯着屏幕,脑子里只有一个念头:这不可能。白名单加了,宿主机通了,为什么又死了?
我傻眼了,于是立马手忙脚乱的再打了一次。
- 宿主机直连(不经过 Nginx)
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的token"}'- 模拟 Nginx 带公网 IP 头
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-H "X-Real-IP: [你的服务器宿主机公网 IP ]" \
-d '{"token":"你的token"}'- 通过域名(Nginx 反代)
curl -s -X POST https://你的域名/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的token"}'输出全是:"message":"认证成功"
这已经不是排查问题了,这是牛鬼蛇神。
我让自己先冷静下来。
2. 复盘
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的token"}'成功。
curl -s -X POST http://127.0.0.1:40653/auth \
-H "Content-Type: application/json" \
-H "X-Real-IP: 服务器宿主机公网 IP" \
-d '{"token":"你的token"}'成功。
curl -s -X POST https://你的域名/auth \
-H "Content-Type: application/json" \
-d '{"token":"你的token"}'也成功。
偏偏浏览器访问失败。
这说明 curl 和浏览器的请求,根本不是同一种请求。
至少,在 QCE 后端眼里,它们根本不是一个玩意。
3. 思考
前面我已经把 Docker 容器的网络路径拆过一遍了。
宿主机请求进入容器时,会经过 Docker 的端口映射和网桥转发。这个过程中,容器内进程看到的 TCP 对端地址,已经不再是原始访问者 IP,而是 Docker 网桥地址,比如 172.17.0.1。
但到了 Nginx 反代这一层,问题又多了一层。
Docker 改的是“真实网络连接里的源 IP”。
我把 Nginx 的配置丢给了 Claude ,让它帮我看看。
几分钟后,Claude 说到:
看到 Nginx 这一行:
proxy_set_header X-Real-IP $remote_addr;改的不是 TCP 源 IP,而是 Nginx 转发给后端时,额外塞进去的一个 HTTP 请求头。
也就是说,QCE 后端实际收到的请求里,会多出这样一个字段:
X-Real-IP: 某个 IP 地址这个 IP 地址来自 $remote_addr。
而 $remote_addr 的含义是:当前这条连接里,Nginx 看到的直接客户端 IP。
如果你在服务器上执行:
curl https://你的域名/auth那 Nginx 看到的客户端,就是是服务器自己,所以 $remote_addr 可能是 127.0.0.1,也可能是服务器自己的公网 IP。
但如果你在家里的浏览器访问:
https://你的域名/qce-v4-tool那 Nginx 看到的客户端,就是你家里宽带的公网出口 IP。
如果我用手机流量访问,就是运营商分配的出口 IP。
如果前面套了 CDN,那 Nginx 看到的就是是 CDN 节点 IP。
所以这行配置的实际效果是:
proxy_set_header X-Real-IP $remote_addr;等于告诉 QCE:
你别看 TCP 连接是从 Docker 网桥来的,我现在通过
X-Real-IP告诉你:真正的访问者是$remote_addr这个 IP。
这本身没有问题,甚至是 Nginx 反代里非常常见的标准写法。
问题在于,QCE 的 getClientIP() 会读取这些代理头。
你前面已经从源码里确认过,QCE 获取客户端 IP 的顺序是:
X-Forwarded-For
→ X-Real-IP
→ req.ip
→ socket.remoteAddress也就是说,在 X-Forwarded-For 被清空或不存在时,只要请求头里带了 X-Real-IP,QCE 就不会再使用 Docker 层看到的 172.x.x.x,而是会使用 Nginx 通过 X-Real-IP 传进来的这个“真实客户端 IP”。
更巧的是,你的 Nginx 配置里还写了:
proxy_set_header X-Forwarded-For "";这等于主动把 X-Forwarded-For 清空了。
于是 QCE 的判断链路就变成了:
X-Forwarded-For 为空
→ 继续看 X-Real-IP
→ 读取到 $remote_addr
→ 把它当成 clientIP
→ 拿这个 IP 去 security.json 里做白名单判断这就是为什么问题最后会落到这一行:
proxy_set_header X-Real-IP $remote_addr;不是因为这行配置写错了。
而是因为它改变了 QCE 用来做白名单判断的 clientIP。
前半场,Docker 让 QCE 看到的是 Docker 网桥 IP。
后半场,Nginx 又通过 X-Real-IP 让 QCE 看到真实访问者 IP。
这两者根本不在一个层级。
| 层级 | 谁在改变 IP | 改的是哪里 | QCE 可能读到什么 |
|---|---|---|---|
| Docker 网络层 | Docker / iptables / 网桥 | TCP 连接源地址 | 172.x.x.x |
| Nginx 反代层 | proxy_set_header | HTTP 请求头 | $remote_addr |
| QCE 应用层 | getClientIP() | 按优先级选择客户端 IP | 优先读代理头 |
知道了吗,需不需要我再细致的讲一遍?
我看完后头皮发麻。
4. 结果
到这一步,方案就很明确了。
如果 QCE 只挂在 Nginx 后面,并且 Docker 端口只绑定本机,然后关闭 QCE 自己的 IP 白名单:
"disableIPWhitelist": true把入口控制交给 Nginx、HTTPS、服务器防火墙、Basic Auth 或其他访问控制手段。
否则,每换一个网络环境,就可能换一个公网 IP。
今天家宽能用,明天手机流量不能用。
今天 IPv4 能用,明天 IPv6 不能用。
今天直连能用,明天套 CDN 不能用。
QCE 的白名单不是不能用,而是在反代环境下,它校验的已经不一定是你以为的那个 IP。
现在回头看,整个问题其实可以用一句话总结:
Token 没错,错的是 QCE 用“Token 错误”的提示,掩盖了 IP 白名单校验失败。
九、Bug 结论
两次拦截,两条独立链路。把这个故障拆开来看,脉络很清晰了。
问题一:Docker SNAT 改变了源 IP
用户浏览器 (公网 IP)
│
▼
Nginx 反向代理 (域名)
│ proxy_pass → 127.0.0.1:40653
│
▼
Docker 端口映射 (SNAT)
│ 宿主机 127.0.0.1 → 容器看到的源 IP 变成 172.17.0.1
│
▼
QCE 进程 (容器内)
│ clientIP = 172.17.0.x
│
▼
SecurityManager.verifyToken(token, clientIP)
│
| Token 正确
│ IP 白名单不对, 172.17.0.x 不在 ['127.0.0.1', '::1'] 中
│
▼
返回 false → 「无效的访问令牌」Docker 默认端口映射(-p 40653:40653)会做 SNAT,从宿主机进入的请求,容器看到的源 IP 是 Docker 网桥 IP(如 172.17.0.1),不在 security.json 的 allowedIPs 白名单中。
SecurityManager.ts 的 initialize() 方法仅在首次生成 security.json 时检测 Docker 环境并自动设置 disableIPWhitelist: true。如果配置文件已存在(持久化卷 / 手动创建),则直接读取,不再更新白名单。
问题二:Nginx 传递了公网 IP 的 X-Real-IP 头
根据源码,getClientIP() 应优先读取 X-Forwarded-For,X-Real-IP 的有无不应影响结果。但实际测试表明:容器内打包运行的 JS 中 getClientIP() 的优先级可能与源码不同,实际优先读取 X-Real-IP。
| 测试条件 | 说明 | 结果 |
|---|---|---|
不带 X-Real-IP,有 X-Forwarded-For | 后端 fallback 到 socket 地址 127.0.0.1 | 200 |
| 带 X-Real-IP: 公网IP,IP 白名单开启 | 后端用公网 IP 做白名单判断,不在列表中 | 403 |
带 X-Real-IP: (空值) | 空值不触发 | 200 |
问题三:为什么官方文档 / 测试没暴露?
- 官方文档的反代示例走的是 Docker 内部网络(
proxy_pass http://napcat:40653),$remote_addr是容器内网 IP,天然在白名单里 - 大多数用户直接暴露端口或用
location/全量代理 - 开发者环境默认
disableIPWhitelist在 Docker 下首次生成时本应为true - 文档完全未提及 Nginx 反代时传递公网 IP 对认证的影响
十、怎么修
查到这里,根因已经没有任何模糊空间:
- Nginx 通过
X-Real-IP把真实客户端公网 IP 传给了 QCE - QCE 拿这个头部里的 IP 去对
allowedIPs白名单 - 公网 IP 永远不在那张静态的白名单里 → 401 / 403,表面全都报 “无效的访问令牌”
那么怎么修,就变成了一个选择题:这张 IP 白名单,到底还要不要?
如果在十几年前,ADSL 时代,固定 IP 还很常见,但在当下的中国网络环境里,这个前提已经不成立了:
- 家庭宽带早就是大内网 + CGNAT,出口 IP 运营商说换就换,甚至一次重拨就变。
- 移动网络更不用说,基站一切、飞行模式一次,IP 直接翻篇。
- 哪怕同一个设备,上午用电信宽带,下午用联通流量,晚上挂公司 Wi‑Fi,QCE 看到的请求来源可以在一小时内变三种公网 IP。
- 再算上 IPv6 的普及,同一个设备可能同时持有几个不同的 IPv6 地址,临时地址、隐私扩展地址轮换,连前缀都可能因重新拨号而改变。
想在这样一张永远在变、不受你控制的公网地址池里,手工维护一份精确有效的 IP 白名单,工作量会直接变成无底洞,而且本质上防不住任何人,只会不断地锁死你自己。
所以修复方向非常明确:关掉这张没人也没法维护的白名单,保留 Nginx 中真实访客 IP 的记录能力。
1. 推荐方案
关闭 IP 白名单,保留真实访客 IP
改动只动一处:/app/.qq-chat-exporter/security.json。
{
"disableIPWhitelist": true
}Nginx 完全不用动,继续保持标准反代写法:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;这样 QCE 会直接跳过 IP 校验,无论 X-Real-IP 里是什么,都放行。但头部本身依然被正常传入后端,你可以在 Nginx 日志、QCE 自己的访问日志里继续看到完整的真实客户端 IP,后续任何安全审计、异常排查都不受影响。
换句话说,这道 IP 校验关掉以后,实际上连“代价”都不存在。
你并没有丢失任何原本有效的信息,只是去掉了一个从未起效的自锁机制。
2. 仅在极其特殊情况下考虑的方案
如果部署环境里有别的强依赖要求必须保留 QCE 自身的白名单机制,那唯一可行的方向是:让 QCE 看到的永远是一个内网地址,不去接触真实公网 IP。
Nginx 里把真实 IP 头擦掉:
proxy_set_header X-Real-IP "";
proxy_set_header X-Forwarded-For "";同时把 Docker 网桥网段加进白名单:
{
"allowedIPs": ["172.16.0.0/12", "192.168.0.0/16"]
}这样一来,QCE 不再从代理头里取 IP,回退到 req.socket.remoteAddress,拿到的是 Docker 网桥地址,正好落在白名单里。
这样一来,从 QCE 到后端日志,全部丢失真实客户端 IP。将来出了任何安全问题,你拿到的是一串 172.x.x.x,完全无法溯源。在任何需要留存真实访问来源的场景下,这个方案均不适用。
3. 两个方案对比
| 方案一(推荐) | 方案二 | |
|---|---|---|
| 需改动的位置 | 仅 security.json 一处 | Nginx + security.json 两处 |
| 真实 IP 保留 | 完整保留 | 完全丢失 |
| IP 白名单防护 | 关闭(实际上从未有效过) | 保留(但只能匹配内网地址,防外不防内) |
| 适用场景 | 所有面向公网、动态 IP 环境的部署 | 仅限不受审计约束、也不需要真实 IP 日志的内部隔离网络 |
| 后续维护 | 零维护 | 仍需维护 allowedIPs 网段,且面临未来 Docker 网络变更风险 |
在今天的中国网络环境下,真实客户端 IP 本身就是一段动态变化的上下文,已经不适合再作为静态鉴权依据。让白名单退休,把身份校验交给 Token、HTTPS 和已有的访问控制,是唯一的长期稳定方案。
4. 结论
只改一行配置,无脑选方案一。
这个问题从头到尾的真相是:
一个默认开着但从未被真正维护的 IP 白名单功能,在整个部署链路里,先用 Docker 网桥 IP、再用 Nginx 传递的公网 IP,反复触发拒绝,而每一次都统一伪装成一句让人崩溃的“无效的访问令牌”。
关掉它,系统恢复正常,真实 IP 继续记录,不用再和运营商、CGNAT、临时 IPv6 地址较劲。
反代层记录访客来源,应用层专注业务逻辑,各管各的,不要再交叉做半吊子的 IP 校验。
AI 辅助
- 代码审查:GLM 5.1
- 技术排查:DeepSeek V4 Pro / Claude Opus 4.6
- 文章撰写辅助:Doubao Seed Character
- 文章技术审核:ChatGPT 5.5 Thinking
- 情绪支持:Grok 4.2 / Gemini 3.1 Pro