16 mins
3161 words
Loading views
磁盘上全是密文?直接读进程内存:把商汤小浣熊变成全自动 OpenAI 网关(CF Worker 实战)

客户端把 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

一、侦查:先摸清目标是什么结构h2

目标是一个 Electron 桌面客户端(商汤「小浣熊」办公助手),装在 C:\Program Files\raccoon-ai\。先看两样东西:

Terminal window
# 进程结构:谁是主进程、谁是 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):

Terminal window
# 全量转储主进程(-ma = 全内存), 几百MB, 一两秒完成
procdump.exe -accepteula -ma <主进程PID> raccoon-main.dmp
# 提取可打印字符串, 只看 URL
strings.exe -n 10 raccoon-main.dmp | Select-String "https?://" | Sort-Object -Unique

URL 一出来就基本破案了:上游是 xiaohuanxiong.com,真正的对话接口是 POST https://xiaohuanxiong.com/api/web/llm/v2/chat/completions——一个 OpenAI 兼容端点。再按 Bearer、refresh_token 等关键词搜,能直接看到认证头和请求体格式。

dump 有六百多 MB,全量 strings 一趟要几分钟,超时就超时,写文件慢慢 grep 也行。更快的做法是写个小脚本按字节读进内存,直接 IndexOf 特征串(比如 JWT 的开头 eyJhbGci),比 strings 快好几倍:

Terminal window
$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 一拆:

Terminal window
$p = $jwt.Split('.')[1].Replace('-','+').Replace('_','/')
while ($p.Length % 4) { $p += '=' }
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p))

这一拆拆出大发现——同一个会话里躺着两个长得很像的 JWT:

access_tokenrefresh_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/refresh
body: {"refresh_token":"..."}

curl 一发,{"code":0, "data":{"access_token":"...","refresh_token":"..."}}——换发成功。注意它返回的是一对新 token:access 是 3 小时新票,refresh 也跟着滚动成新的 30 天票。

六、第二个坑:单活会话,会「互殴」的 refresh_tokenh2

高兴了没五分钟就栽了。我把刷新流程固化成脚本,反复测试,突然全挂。排查发现一个残忍的事实:

这套服务是单活会话。refresh_token 每刷新一次,旧的立即作废;客户端重新登录一次,旧会话整条吊销。于是我和客户端之间上演了死循环:

  • 我用脚本刷新 → 客户端手里的链作废 → 客户端检测到 401 自动重登 → 我手里的链作废 → 再见

排错实录(真实时间线,字字血泪):

  1. 我手动刷新成功,把新链注入脚本;
  2. 客户端例行刷新失败(链被我占了),触发自动重登;
  3. 新登录把我的会话顶死,脚本链 401;
  4. 反复三次后确认机制,停止无谓挣扎。

结论: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> 的心跳对话h2

Worker 最怕的不是技术,是「闲置」:如果渠道一整月没人用,没人触发续期,refresh_token 静止 30 天后自然死亡。

解法简单到好笑——给 Worker 加一个 cron trigger,每天 07<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 小时)被同会话新刷新顶掉(单活)每次刷新即轮换,保证「一个链一个持有者」
刷新接口 401refresh_token 被另一端轮换/重登吊销从 ~/.box-agent/config/auth.json 重新提取,或客户端重登后提取
KV put() limit exceededKV 免费版写入 1000 次/天打满凭证改存 D1(10 万次写/天)
自定义域名不生效TOML 表后裸键归属错误routes 必须放在任何 [[表]] 之前
绑域名后老地址 404CF 自动禁用 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。请遵守上游服务条款使用自己的账号,注意单活会话机制对原客户端的影响,风险自负。


Author: 夜猫子Ai手记
Post: 磁盘上全是密文?直接读进程内存:把商汤小浣熊变成全自动 OpenAI 网关(CF Worker 实战)

Comments