a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
|
Raw History Blame Contribute Delete
17.3 kB
# WorkBuddy2API — Cloudflare Worker:反代 + 保活
把公网入口 `cli.3216gemini.dpdns.org` 反代到 Hugging Face Space
`a3216/gcli2api`(直连域名 `https://a3216-gcli2api.hf.space`),
并用 Cron 定时打 `/healthz`,防止 Space 被 HF 暂停。
## 这个 Worker 做什么
| 能力 | 说明 |
|---|---|
| 🔁 **全量反向代理** | 方法 / 路径 / query / 请求体 / 端到端请求头原样转发,源站的状态码、响应头、响应体原样回传 |
| ⚡ **零缓冲流式** | 直接返回 `originResponse.body`,不调用 `.text()` / `.json()`。`POST /v1/chat/completions` 是 SSE 长连接,缓冲会直接毁掉流式体验 |
| ↩️ **不吞后端 302** | `fetch(..., { redirect: 'manual' })`,所以 `GET /` → `/panel/` 的跳转是**浏览器**看到的,不会被 Worker 内部跟掉 |
| 🌐 **CORS 全透传** | 后端已经返回宽松的 CORS 头,Worker 一律不自己造 `Access-Control-*`,避免出现重复头 |
| 🧹 **逐跳头剥离** | 剥掉 `host` / `connection` / `keep-alive` / `transfer-encoding` / `upgrade` / `proxy-*` / `cf-*` / `x-forwarded-*`;`Host` 永远不会用公网域名转发过去 |
| 🍪 **set-cookie 原样** | 不做 domain / path 改写,多条也完整保留 |
| ⏰ **Cron 保活** | `scheduled()` 每 5 分钟 ping 一次源站 `/healthz`,25s 硬超时 + 失败重试 1 次 + 每次一行日志 |
| 🛡️ **不抛异常** | Cron 处理器内部吞掉所有异常,不会污染 Cron 运行记录 |
涉及的后端接口(都不需要 Worker 特殊处理,纯透传):
- `GET /healthz` → `{"healthy":2,"realm_servable":{...},"service":"workbuddy2api","total":3}`(无可服务账号时 503)
- `GET /` → 302 → `/panel/`
- `GET /panel/` → 单页管理面板
- `/panel/api/*` → 面板 JSON API,需 `Authorization: Bearer <api_key>`
- `POST /v1/chat/completions` → OpenAI 兼容,**SSE 流式**,需同一个 Bearer 密钥
- `GET /v1/models`、`GET /status`
## 为什么需要这个 Cron
Hugging Face 免费 `cpu-basic` Space 有一条硬规则:**连续 48 小时没有任何 HTTP 流量就被暂停**,
下一次访问要等 **30–60 秒**冷启动。
所以「反代」和「保活」其实是同一件事的两半:反代提供入口,Cron 保证入口后面那台机器不会睡着。
只要有任意 HTTP 请求打到 Space,HF 的闲置计时器就会归零——**响应内容是什么完全不影响判断**,
这就是为什么一次裸的 `GET /healthz` 就够了。
## ⚠️ 接管前必读:现有入口的 `/healthz` 是 Cloudflare 自己应答的
同一时刻实测对比:
| 目标 | `/healthz` 响应 | 判据 |
| --- | --- | --- |
| `https://a3216-gcli2api.hf.space/healthz` | `{"healthy":2,…,"service":"workbuddy2api"}` | 带 `x-service`、`x-proxied-replica`,以及 HF 的 `link: …/spaces/a3216/gcli2api` 头 |
| `https://cli.3216gemini.dpdns.org/healthz` | **`ok`(2 字节)** | 只有 `cf-ray`;**没有** `x-service`、**没有** `link` |
**结论:现有入口把 `/healthz` 短路了,请求根本没到 Space。** 很可能是故意的 ——
避免探活 / 监控 / 负载均衡把 30~60s 冷启动打起来。它只短路了这一个路径:
`/status`、`/panel/*` 等仍是透传的(两边都带 HF 的 `link` 头,可自证)。
两个直接后果:
1. **千万别拿 `cli.3216gemini.dpdns.org/healthz` 做保活。** 它会稳定返回 200、
看起来一切健康,但 Space 完全没被唤醒 —— 48 小时一到照样被暂停。
保活必须打 Space 的直连域名(见仓库根的 `.github/workflows/space-keepalive.yml`)。
2. **本 Worker 接管路由后,`/healthz` 的行为会变**:它会把 `/healthz` 原样透传给 Space,
于是返回的是网关 JSON 而不是 `ok`。对"真探活"来说这是**更正确**的
(能反映账号可用性),但如果你有监控依赖那个 `ok`,请先调整它。
想让本 Worker 也短路 `/healthz`,在 `worker.js` 的 `fetch()` 里加一条早返回即可。
## ⚠️ 免费版只有 5 个 Cron(最容易踩的坑)
**Cloudflare 免费版的 cron 触发器限制是「每个账号 5 个」,不是每个 Worker 5 个。**
如果你账号里别的 Worker 已经占了 5 个,再部署本 Worker 就会看到这种**半成功**:
```text
Uploaded workbuddy2api-hf-proxy (1.63 sec)
Deployed workbuddy2api-hf-proxy triggers (1.62 sec)
✘ [ERROR] Trigger configuration for "workbuddy2api-hf-proxy" was only partially updated:
Cron schedules: ... failed.
- This account has reached the Workers Free limit of 5 cron triggers per account.
[code: 10072]
Successful trigger changes were not rolled back.
```
**怎么读这段**:Worker 的**代码确实上传成功了**,但 **cron 没加上**,而且
"Successful trigger changes were not rolled back" 只是说"已成功的部分不回滚"。
结果是这个 Worker 处于**空转**状态——没有 cron,它不会去唤醒任何人。
(`* * * * *` 这种多行 crons 数组里**每一行都算一个**触发器,别写成 5 行。)
两条出路:
### 出路 A:腾一个 CF cron 名额(保持 CF 原生方案)
Dashboard → Workers & Pages → **逐个 Worker** 看 Settings → Triggers → Cron Triggers,
找出已经不用的那个删掉,然后重新 `npx wrangler deploy`。
想用 API 一次性列全账号的 cron(需要 `CLOUDFLARE_API_TOKEN`,权限含 *Workers Scripts:Read*):
```bash
ACC=a75c5f86fdd1b75eb3e860f5436a7970 # 你的 account id
for w in $(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[].id'); do
echo "== $w"
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts/$w/schedules" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -c '.result.schedules'
done
```
### 出路 B:改用 GitHub Actions(**推荐,无数量限制**)
本仓库自带 `.github/workflows/space-keepalive.yml`,每 6 小时 ping 一次 Space,
零成本、不占 CF 名额。**而且 6 小时一次完全够用**——HF 的判定阈值是 48 小时,
所以 GitHub 定时任务偶发延迟几十分钟毫无影响(反过来说,本 Worker 里 `*/5` 的
5 分钟粒度其实是**严重过量**的,纯粹因为免费额度够用才这么写)。
两条路不冲突:Worker 留着做反代,保活交给 Actions 也完全可以。
## 部署
> **先看上一节**:如果你账号的 5 个 cron 名额已满,`wrangler deploy` 会报 `code: 10072`,
> 这时按「出路 A/B」处理。
### 1. 前置条件
```bash
node -v # 需要 Node 18+
npx wrangler -v # 首次会提示安装 wrangler
npx wrangler login # 登录你的 Cloudflare 账号
```
### 2. 部署
```bash
cd D:\workbuddy2api-panel\deploy\cloudflare-worker
npx wrangler deploy
```
部署成功后 Worker 名称为 `workbuddy2api-hf-proxy`(见 `wrangler.toml` 的 `name`)。
### 3. 接线路由
`wrangler.toml` 里的 `routes` **默认是注释掉的**,两种接法任选其一:
- **A. 交给 wrangler**:取消 `routes` 注释后重新 `wrangler deploy`,路由自动创建;
- **B. 在 Dashboard 手动加**:Workers → 你的 Worker → Settings → Domains & Routes →
添加 `cli.3216gemini.dpdns.org/*`(推荐,避免误删线上路由)。
> ⚠️ 启用路由前确认该主机名当前没有指向别处的「已代理」DNS 记录,
> 否则 Worker 路由和 DNS 记录会互相打架。正确做法是该主机名由 Worker 路由接管
> (DNS 里留一条指向 `100::` 的 AAAA 占位记录)。
### 4. 设置密钥(可选)
只有开启「可选唤醒/签到」时才需要。**密钥永远不要写进 `wrangler.toml`,也不要提交。**
```bash
npx wrangler secret put WB2A_API_KEY
# 粘贴网关的 api_key(与面板登录用的是同一个),回车即可
```
未设置时 `scheduled()` 只做 `/healthz` 裸 ping,不发送任何鉴权请求。
## 验证
> **先接路由再验**:下面这些 URL 打的是 `cli.3216gemini.dpdns.org`,只有在该主机名已经
> 路由到本 Worker 时才是"在验 Worker"。没接路由的话,你验到的仍是原来的链路。
>
> **`*.workers.dev` 在部分网络下不可用**(国内常见:DNS 被污染,解析到不相干的 IP、
> 443 不通)。所以 `wrangler deploy` 打印的 `https://<worker>.<sub>.workers.dev`
> 在你这里可能**根本连不上**——这**不代表部署失败**,用自定义域名的路由来验即可。
```bash
# 探活:能看到网关自己的 JSON,且带 total / service 字段
curl -i https://cli.3216gemini.dpdns.org/healthz
# 后端自带的 302 应该原样到达客户端(不要出现 -L 跟随)
curl -i https://cli.3216gemini.dpdns.org/
# 面板
curl -I https://cli.3216gemini.dpdns.org/panel/
# 流式:应逐块吐出,而不是憋到最后一次性返回
curl -N -H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash","stream":true,"messages":[{"role":"user","content":"hi"}]}' \
https://cli.3216gemini.dpdns.org/v1/chat/completions
```
看到 `server: cloudflare` 与 `cf-ray` 属正常——那是 Cloudflare 边缘加的,不代表后端没响应;
后端的响应头(如 `service`、`x-*`)应当同时原样出现。
## 看日志
```bash
cd D:\workbuddy2api-panel\deploy\cloudflare-worker
npx wrangler tail # 实时请求日志
npx wrangler tail --format pretty # 人类可读格式
```
> **`wrangler tail` 报 `Error: Unexpected server response: 400`?**
> 它走的是 WebSocket,经过本地抓包代理(DevSidecar / Reqable 之类)时经常被挡掉。
> 换成这几条路即可:
>
> - Dashboard → Workers & Pages → 你的 Worker → **Logs** → Live / Cron Events(最省事);
> - 关掉代理再 `npx wrangler tail`;
> - 或者干脆不依赖 tail:Cron 有没有在跑,**看 Space 的 `last_checkin_day` 有没有变成当天**
> 最直接(桶里的 `data/state.json`),或看 `npx wrangler deployments list` 的更新时间。
Cron 每次运行会打一行:
```
[keepalive] 2025-01-01T00:05:03.412Z /healthz -> HTTP 200 (183ms)
```
失败时是 `warn` 级别,同时带上首次失败原因,便于区分「偶发抖动」和「真的没醒」:
```
[keepalive] 2025-01-01T00:05:03.412Z /healthz -> FAILED status=0 (25013ms) first=ping timeout detail=ping timeout
```
### Cron 粒度说明
- `[triggers] crons = ["*/5 * * * *"]` = 每 5 分钟一次,**这是 Cloudflare Cron Triggers 的最小粒度**(不支持每分钟);
- 频率换算:每 5 分钟 ≈ 288 次/天,对比 48 小时暂停阈值,余量极大;
- Cron 触发时间有**秒级抖动**(官方说明可能延迟数秒到数十秒),对保活完全无影响;
- 如果只是防休眠,**不需要再加密**;调成 `*/30` 之类也够用,但 5 分钟最稳。
## 本地调试
**推荐:先用 `dev-server.mjs` 把代理逻辑验完,再考虑 deploy。** 它用 Node 起一个本地服务,
**原样 `import` 这个 worker.js**(不做任何改写),转发到真实的 HF Space——不需要 wrangler、
不需要登录:
```bash
cd deploy/cloudflare-worker
node dev-server.mjs # 监听 127.0.0.1:8787
# PORT=9000 node dev-server.mjs # 换端口
# ORIGIN=https://other.hf.space node dev-server.mjs
```
另开一个终端逐条验:
```bash
curl -i http://127.0.0.1:8787/ # 期望 302 → /panel/(不要加 -L)
curl -i http://127.0.0.1:8787/healthz # 期望网关自己的 JSON(带 total / service)
curl -i http://127.0.0.1:8787/status # 期望 401 invalid_api_key(说明真的打到了网关)
curl -s http://127.0.0.1:8787/__cron # 手动跑一次保活定时器,看控制台日志
```
`/__ping` 与 `/__cron` 是**本地专用**路径,永远不会被代理到源站,部署后的 Worker 里没有它们。
也可以走 wrangler(需要它自己的本地环境):
```bash
npx wrangler dev
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # 临时触发 Cron 处理器
```
### `.dev.vars`
本地调试需要的变量(尤其是 `WB2A_API_KEY`)放在同目录 `.dev.vars` 里,**不要提交**:
```bash
cp .dev.vars.example .dev.vars # 然后按需填写
```
格式为 `KEY="value"`,一行一个。`wrangler dev` 会自动读取该文件中的变量与密钥。
> ✅ **`.gitignore` 已经覆盖到位**(根目录规则):`.dev.vars` / `.dev.vars.*` / `.wrangler/`
> 全部被忽略,同时用 `!.dev.vars.example` 把**模板**放回版本库(模板里只有占位符)。
> 四个可提交文件:`worker.js`、`wrangler.toml`、`.dev.vars.example`、本 `README.md`。
>
> **任何 secret 都不得提交**,线上一律走 `wrangler secret put`。
## 环境变量一览
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `ORIGIN` | vars | `https://a3216-gcli2api.hf.space` | 源站地址。换 Space 只改这里 |
| `ROOT_REDIRECT` | vars(注释中) | `/panel/` | `GET /` 的跳转目标;设成空串则关闭跳转、直接透传后端的 404 |
| `HEALTH_PATH` | vars(注释中) | `/healthz` | 保活探测路径 |
| `CHECKIN_CRON` | vars(注释中) | 空 = 关闭 | 只在此 Cron 表达式触发时做签到 |
| `CHECKIN_PATH` | vars(注释中) | 空 = 关闭 | 带 Bearer 调用的路径,如 `/panel/api/keepalive` |
| `WB2A_API_KEY` | **secret** | 未设置 | 面板/网关 Bearer 密钥,仅签到功能需要 |
> `ORIGIN` 若填成非法 URL,Worker 会**回落到默认源站**而不是整体 500——配置拼错时仍能提供服务。
## 如果你已有自己的反代 Worker
**不要替换掉你现有的反代逻辑**,只把「保活」那一小块搬过去即可,改动量极小:
1. **加 `scheduled()` 处理器**:从 `worker.js` 里复制 `scheduled(event, env, ctx)` 整个函数,
连同它依赖的 `DEFAULT_ORIGIN` / `HEALTH_PATH` / `PING_TIMEOUT_MS` / `PING_RETRY_DELAY_MS`
常量、`resolveOrigin()` / `normalizePath()` 两个工具函数一起拿走。
(可选签到那段如果不需要,连同 `CHECKIN_*` 一起删掉即可。)
2. **确认你的默认导出里有 `scheduled`**:必须是
`export default { fetch(request, env, ctx) {...}, scheduled(event, env, ctx) {...} }` 这种形状。
如果你原来是 `export default { async fetch(...) }`,直接把 `scheduled` 加进同一个对象即可,
**不要**改成 `export { scheduled }` 之外的写法。
3. **在 `wrangler.toml` 加上 trigger 块**:
```toml
[triggers]
crons = ["*/5 * * * *"]
```
4. **重新部署**:`npx wrangler deploy`。改 `[triggers]` 必须重新部署才会生效,
只改 Dashboard 里的 Cron 是临时的、下次部署会被覆盖。
反代部分如果你已经有实现,**保持原样**——本 Worker 的 `fetch()` 只是一个满足
「不缓冲 / 不跟随 302 / 剥离逐跳头 / 透传 CORS」四条硬约束的参考实现,
你的版本只要满足同样四条,效果完全等价。
## 常见问题
### 代理后 `/v1/chat/completions` 变成一次性返回,没有流式效果?
几乎一定是某处调用了 `await res.text()` / `res.json()` / 中间做了缓冲(包括自己包一层
`TransformStream` 却忘了 flush)。确认直接 `return new Response(originResponse.body, ...)`。
### `GET /` 返回的是 `/panel/` 的内容,看不到 302?
说明 `fetch` 的 `redirect: 'manual'` 丢了。默认的 `redirect: 'follow'` 会在 Worker 内部
把 302 跟掉,客户端就永远看不到那次跳转。
### `GET /` 到底该返回 302 还是后端原始的 404?
**302 → `/panel/`。** 这是实测出来的既有行为,值得记一笔:网关只注册了 `/panel/`,
所以**直连 Space 的 `GET /` 是 404**;但公网入口 `https://cli.3216gemini.dpdns.org/`
返回的却是 302 → `/panel/`。同一路径两种结果,说明这层跳转是 **CF 侧加的**(Worker 或
Redirect Rule)。`worker.js` 因此也自带了这个跳转(`ROOT_REDIRECT`,默认 `/panel/`),
换用本 Worker 后直接打开域名首页的体验不变。
想对比两种行为:
```bash
curl -i https://a3216-gcli2api.hf.space/ # 404(后端真实行为)
curl -i https://cli.3216gemini.dpdns.org/ # 302 → /panel/(入口层加的)
```
想把跳转关掉(严格透传后端):把 `ROOT_REDIRECT` 设成空串。
### 浏览器报 CORS 重复头错误?
Worker 自己造了 `Access-Control-Allow-Origin`,而后端也返回了同一个头。
删掉 Worker 里的 CORS 处理,让它纯透传。
### Cron 日志里一直是 503,是不是保活失败了?
**不是。** 503 是后端「当前没有可用账号可服务」的业务语义,但只要它回了 HTTP,
Space 就是醒着的,保活目的已经达到。只有 `status=0`(超时/网络失败)才需要关注——
那通常意味着正在冷启动,或者 Space 真的挂了。
### 想确认 Cron 到底有没有在跑?
```bash
npx wrangler tail --format pretty # 等 5 分钟就能看到 [keepalive] 行
```
或者 Dashboard → Workers → 你的 Worker → Logs → 筛选 Cron Events。