客户端把 API 凭证加密存在磁盘上,但只要它要调用接口,就必然把明文 token 放进内存。本文完整复盘:procdump 全量转储 + 字符串提取拿到 baseurl / key / model,解码 JWT 发现双 token 体系,踩遍拿错 token 的 401、单活会话互殴、KV 写入配额耗尽等连环坑,最终用 Cloudflare Worker + D1 做成全自动网关——access_token 自续期、每天 07:00 心跳对话保活、多 Key 管理、请求级用量观测一步到位。
一句话思路:磁盘上确实是密文,我没解开它——我绕过去了,直接读进程内存。 因为客户端要用 token,就必须把它解密后放进内存里。这篇记录从「拿到一个 Electron 客户端」到「全自动 OpenAI 兼容网关跑在边缘节点上」的完整过程,中途踩了一串非常典型的坑,每个都有解法,文末附排错速查表。完整代码已开源:miaopasixx/xhx-2api。
📑 目录h2
- 一、侦查:先摸清目标是什么结构
- 二、Step 1 全量内存转储 + 字符串提取
- 三、Step 2 解码 JWT:挖出双 token 体系
- 四、第一个坑:401,因为我把 refresh_token 当 API Key 填了
- 五、Step 3 从内存里挖出刷新端点
- 六、第二个坑:单活会话,会「互殴」的 refresh_token
- 七、柳暗花明:原来磁盘上有一份明文
- 八、Step 4 本地网关:零干扰模式
- 九、Step 5 上云:Cloudflare Worker + D1
- 十、第三个坑:KV 写入配额 + TOML 的坑
- 十一、点睛之笔:每天 07<00>00> 的心跳对话
- 十二、管理台与用量观测
- 排错速查表
- 声明
一、侦查:先摸清目标是什么结构h2
目标是一个 Electron 桌面客户端(商汤「小浣熊」办公助手),装在 C:\Program Files\raccoon-ai\。先看两样东西:
# 进程结构:谁是主进程、谁是 renderer、谁管网络Get-CimInstance Win32_Process -Filter "Name='商汤小浣熊.exe'" | Select-Object ProcessId, ParentProcessId, CommandLine输出一目了然:一个 --updated 主进程(Node 主逻辑,token 大概率在这)、几个 --type=renderer(前端)、一个 --utility-sub-type=network.mojom.NetworkService(网络服务)。
再扫一眼安装目录,app.asar、resources.pak、locales——标准 Electron 打包。磁盘上的配置文件(如 Electron 的 localStorage)多半是密文,不用跟它较劲,直接进入内存路线。
二、Step 1:全量内存转储 + 字符串提取h2
Sysinternals 的 procdump 和 strings 一对组合拳(winget 可装,装完记得 -accepteula):
# 全量转储主进程(-ma = 全内存), 几百MB, 一两秒完成procdump.exe -accepteula -ma <主进程PID> raccoon-main.dmp
# 提取可打印字符串, 只看 URLstrings.exe -n 10 raccoon-main.dmp | Select-String "https?://" | Sort-Object -UniqueURL 一出来就基本破案了:上游是 xiaohuanxiong.com,真正的对话接口是 POST https://xiaohuanxiong.com/api/web/llm/v2/chat/completions——一个 OpenAI 兼容端点。再按 Bearer、refresh_token 等关键词搜,能直接看到认证头和请求体格式。
dump 有六百多 MB,全量 strings 一趟要几分钟,超时就超时,写文件慢慢 grep 也行。更快的做法是写个小脚本按字节读进内存,直接 IndexOf 特征串(比如 JWT 的开头 eyJhbGci),比 strings 快好几倍:
$bytes = [IO.File]::ReadAllBytes($dmpPath)$text = [Text.Encoding]::GetEncoding(28591).GetString($bytes) # Latin-1, 1:1映射# 然后循环 IndexOf('eyJhbGci') 逐个截取连续的 base64url 字符即得全部 JWT模型名也是同理搜出来的:内存里有路由日志 model=sn-deepseek-v4-1-flash ... selected_model=...,以及 raccoon-chat-ml-5-5、sn-glm-5-3、sn-kimi-k3 一批模型名。
三、Step 2:解码 JWT:挖出双 token 体系h2
提取到的是 JWT。header + payload 用 base64url 一拆:
$p = $jwt.Split('.')[1].Replace('-','+').Replace('_','/')while ($p.Length % 4) { $p += '=' }[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p))这一拆拆出大发现——同一个会话里躺着两个长得很像的 JWT:
| access_token | refresh_token | |
|---|---|---|
| 有效期 | 约 3 小时 | 30 天 |
| 用途 | 调用所有业务 API(当 Bearer 填) | 只能干一件事:换新 token |
| 每次刷新 | 立即失效 | 滚动出新的 30 天 |
坑就此埋下。
四、第一个坑:401,因为我把 refresh_token 当 API Key 填了h2
我当时把那个「长」的 JWT(30 天的)填进了渠道,心想长的好用。结果:
{"code":200003,"message":"authorization_verify_error"}实测两种 token 分别当 Bearer 调对话接口:access_token 通(200),refresh_token 死活 401。服务端把两种令牌的授权范围切死了——refresh_token 在服务端只授权「调刷新接口」这一个动作,拿去调业务接口一律拒绝。这是标准 OAuth2 双 token 设计,跟猜的一样。
教训:长 token ≠ API Key。想接业务接口,必须用短的那个;长的那个只配用来「续命」。
五、Step 3:从内存里挖出刷新端点h2
短 token 3 小时就过期,手工换等于每 3 小时上一次班,必须找到刷新协议。继续 grep dump,客户端的混淆代码在内存里是明文 JS,刷新逻辑整段躺在那:
async function refreshAccessToken(refreshToken) { const response = await fetch(`${getAuthApiUrl()}/refresh`, { body: JSON.stringify({ refresh_token: refreshToken }), }); // 返回 data.access_token + data.refresh_token}再顺着 getAuthApiUrl 的实现和一堆 NEXT_PUBLIC_* 环境变量,拼出完整端点:
POST https://xiaohuanxiong.com/api/web/auth/v1/refreshbody: {"refresh_token":"..."}curl 一发,{"code":0, "data":{"access_token":"...","refresh_token":"..."}}——换发成功。注意它返回的是一对新 token:access 是 3 小时新票,refresh 也跟着滚动成新的 30 天票。
六、第二个坑:单活会话,会「互殴」的 refresh_tokenh2
高兴了没五分钟就栽了。我把刷新流程固化成脚本,反复测试,突然全挂。排查发现一个残忍的事实:
这套服务是单活会话。refresh_token 每刷新一次,旧的立即作废;客户端重新登录一次,旧会话整条吊销。于是我和客户端之间上演了死循环:
- 我用脚本刷新 → 客户端手里的链作废 → 客户端检测到 401 自动重登 → 我手里的链作废 → 再见
排错实录(真实时间线,字字血泪):
- 我手动刷新成功,把新链注入脚本;
- 客户端例行刷新失败(链被我占了),触发自动重登;
- 新登录把我的会话顶死,脚本链 401;
- 反复三次后确认机制,停止无谓挣扎。
结论:refresh_token 只能有一个持有者。要么客户端用、要么网关用,不能既要又要。想两全只有一个办法——让客户端退出登录,或者干脆注册个专用账号给网关。
七、柳暗花明:原来磁盘上有一份明文h2
折腾半天内存,回头一搜,发现更妙的:客户端把认证状态明文写在一个 JSON 里:
C:\Users\<用户>\.box-agent\config\auth.json{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "eyJhbGciOiJIUzI1NiIs...", "office_identity": "personal"}同目录 config.yaml、model-profiles.json 也全是明文——baseurl、默认模型、上下文窗口一应俱全。Electron 层的存储确实加密,但真正的执行器(box-agent,一个 Python 运行时)用的是明文配置。
这直接解锁了第二种取 token 姿势,零干扰模式:客户端开着时它自己会每 3 小时刷新一次并写回这个文件,谁想用 API 就读这个文件——不触发刷新、不顶客户端会话,和平共处。
八、Step 4:本地网关:零干扰模式h2
把上面所有认知捏成一个本地代理,OpenAI 格式进、OpenAI 格式出,token 全自动:
# raccoon-proxy.py 核心逻辑(节选, 完整版见仓库)AUTH_FILE = r'C:\Users\PC\.box-agent\config\auth.json'
def get_access_token(): auth = json.load(open(AUTH_FILE, encoding='utf-8')) if jwt_exp(auth['access_token']) - time.time() > 120: return auth['access_token'] # 客户端维护的新鲜key, 零干扰直接用 log('key 过期, 尝试刷新接管...') new = call_refresh(auth['refresh_token']) # 客户端没在跑才走到这 auth['access_token'] = new['access_token'] auth['refresh_token'] = new['refresh_token'] json.dump(auth, open(AUTH_FILE, 'w', encoding='utf-8')) # 写回, 客户端下次启动可无缝接上 return new['access_token']策略是「谁活着谁续期」:客户端活跃时网关只读文件;客户端关机超过 3 小时,网关才用 refresh_token 接管,而且把新 token 写回 auth.json——下次客户端启动还能无缝接上同一条链,不互殴。
渠道配置改成 http://127.0.0.1:8807/v1,从此不用再碰任何 key。流式 SSE 也做了逐块透传,curl 实测通过。
九、Step 5:上云:Cloudflare Worker + D1h2
本地网关有个天然缺陷:机器关了渠道就断。最终形态是搬上 Cloudflare Workers——边缘常驻、免费额度(10 万请求/天)绰绰有余、无需任何服务器。
架构很直白:
渠道 → https://域名/v1 (GATEWAY_KEY) ↓ Worker: 鉴权 → 取D1里的token → 透传上游 ↓ token过期? 调refresh → 新token写回D1(30天滚动) ↓ 每天07:00 cron: 发一条对话保活 xiaohuanxiong.com凭证存 D1(SQLite),Worker 每次请求读、过期就刷、刷完写回。初始化只要把 refresh_token POST 到 /init 端点一次,之后 30 天滚动、永不再管。
十、第三个坑:KV 写入配额 + TOML 的坑h2
上云过程也不平坦,两个坑值得记录:
坑 1:KV 免费版写入配额 1000 次/天。我第一版把凭证存 KV,调试一天直接打满配额,接口报 KV put() limit exceeded for the day。换 D1(免费版 10 万次写/天)后彻底解决。
坑 2:TOML 表归属。wrangler.toml 里我把 routes 写在了 [[d1_databases]] 后面,按 TOML 规范,表头之后的裸键归属最后一个表——routes 被悄悄吞进了 d1 配置,自定义域名死活不生效。排查思路是把 routes 挪到文件顶部任何表头之前,部署输出里立刻出现 xhx.mpsxx.eu.org (custom domain)。
另外提醒:绑定自定义域名后,CF 会自动禁用 workers.dev 路由,渠道必须换新域名,老地址直接 404。
十一、点睛之笔:每天 07<00>00> 的心跳对话h2
Worker 最怕的不是技术,是「闲置」:如果渠道一整月没人用,没人触发续期,refresh_token 静止 30 天后自然死亡。
解法简单到好笑——给 Worker 加一个 cron trigger,每天 07<00>00> 让它以用户身份发一条真实对话:
// raccoon-worker.js 的 scheduled handler(节选)async scheduled(event, env) { await keepaliveCore(env, 'cron'); // 取token(必然触发刷新) + 发一条对话} // 失败时向 NOTIFY_URL 发 webhook 告警间隔 24 小时 > token 有效期 3 小时,所以每天这次调用必然触发刷新,refresh_token 随之滚动成新的 30 天——只要网关活着,链条永不到期。而且发的是真对话而不是裸调刷新接口,顺带端到端验证了整条链路,一举两得。
十二、管理台与用量观测h2
最后把网关升级成「有仪表盘的网关」,Worker 直接托管了一个控制台页面 /admin(单文件 HTML,零外部依赖):
- 30 天脉搏带:每天一个点,成功蓝、失败红,一眼看整月状态
- 凭证状态:双 token 剩余倒计时、上次续期时间
- 手动保活:一键触发心跳对话,实时看 AI 回复证明链路活着
- 多 Key 管理:初始密钥(防锁死)/ 主密钥(可替换)/ 渠道密钥(仅业务权限,可增删停用),渠道密钥访问管理 API 直接 401
- 用量观测:每次请求记录时间 / 模型 / 密钥 / 成败 / 流式类型 / 输入输出 Token / 首字延迟 / 端到端 / 吞吐量 / 结束原因;今日与 7 日统计、按模型分组,90 天自动清理
其中流式的观测用了 TransformStream 边转发边解析 SSE(不缓冲、不破坏流式体验),在 flush 里 resolve 一个 promise 交给 ctx.waitUntil 写库——这是 CF Workers 流式后处理的标准姿势。实测上游在最后一个 chunk 自动携带 usage,所以流式请求的 token 数也是精确的;唯一拿不到的是缓存命中(上游 prompt_tokens_details 恒为空,服务端根本不下发)。
本站的网关控制台就部署在 https://xhx.mpsxx.eu.org/admin,打开后输入管理密钥即可解锁:30 天脉搏带、双 token 倒计时、保活触发历史、请求明细与用量统计、模型增删、密钥管理全部在一个页面里实时可查,忘记渠道参数时打开它比翻配置文件快得多。
🗝️ 部署信息备忘(防忘)h3
控制台地址:https://xhx.mpsxx.eu.org/admin
管理密钥:默认密文折叠,点击显示 👇
🔑 点击显示管理密钥(明文)
mpsxx-xhx
这把密钥同时具备管理 + 业务权限,写在这里纯属给自己备忘。如果你是读者,请勿拿去调用,发现被白嫖我会立刻更换并撤销此段。
排错速查表h2
| 症状 | 根因 | 解法 |
|---|---|---|
渠道 401 authorization_verify_error | 拿 refresh_token 当 API Key 用 | 只能用 access_token;refresh 只能调刷新接口 |
| access_token 突然 401(没到 3 小时) | 被同会话新刷新顶掉(单活) | 每次刷新即轮换,保证「一个链一个持有者」 |
| 刷新接口 401 | refresh_token 被另一端轮换/重登吊销 | 从 ~/.box-agent/config/auth.json 重新提取,或客户端重登后提取 |
KV put() limit exceeded | KV 免费版写入 1000 次/天打满 | 凭证改存 D1(10 万次写/天) |
| 自定义域名不生效 | TOML 表后裸键归属错误 | routes 必须放在任何 [[表]] 之前 |
| 绑域名后老地址 404 | CF 自动禁用 workers.dev 路由 | 渠道 Base URL 换成自定义域名 |
| 脚本中文注释乱码 | PS5.1 对无 BOM 的 UTF-8 按 GBK 解析 | [IO.File]::WriteAllText(..., UTF8Encoding($true)) 加 BOM |
| PS 传 JSON 参数被剥引号 | -d '{\"k\":\"v\"}' 转义在 PS5.1 失效 | 写临时文件 --data "@file",或避免 -d 直拼 |
声明h2
本文仅记录技术学习过程,完整代码开源在 miaopasixx/xhx-2api。请遵守上游服务条款使用自己的账号,注意单活会话机制对原客户端的影响,风险自负。
Comments