一、1Panel 部署器三个缺陷(其中两个此前完全不可见)
1. `POST /websites/{id}/https` 的字段名是 `websiteSSLId`,不是 `sslId`。
发 `sslId` 会被 Go 静默忽略成零值 0,于是
`websiteSSLRepo.GetFirst(WithByID(0))` → 返回
`HTTP 200 + code 500「服务错误: record not found」`,
报错文案落在 DB 层,完全指不到参数名 —— 整个 t-t.live 部署被这条卡住。
对照实验:`sslId=13 → 500` / `websiteSSLId=13 → 200 code=200`。
2. 换证书内容的接口选错。`POST /websites/ssl/update` 的结构体
`WebsiteSSLUpdate` **根本没有** `certificate` / `privateKey` 字段,
传了被丢弃、且 `domains` 只从 `otherDomains` 取(不传就清空),
还会顺带把 `autoRenew` 置 false —— 而它**照样返回 200 success**。
实测:原样 update 后 5 个站点的 `ssl/*.pem` mtime+md5 一个都没变。
正确接口是 `POST /websites/ssl/upload` + `sslID > 0`:取记录 → 覆盖
→ 重算 ExpireDate/domains → `UpdateSSLConfig()` → 重新物化站点文件。
副作用:`Upload()` 把 `primaryDomain` 重算成证书第一个 SAN
(#11 因此从 `usj.cc` 漂成 `*.usj.cc`)→ 必须补 `domains` 兜底匹配,
否则每次续期都新建一条重复记录。
3. `deploy()` 幂等捷径漏了「记录被换过」这一维:`sslId` 没变但内容变了时
会跳过绑定,站点文件就停留在旧证书。改为引入 `replaced` 标志强制重绑。
二、另外两处静默失败
4. `parsePemInfo()` 对**完整链**返回 `{}`:旧实现把 PEM 各段 base64 拼接后
一次 `atob`,中间段尾部的 `=` 填充导致抛错,整函数返回空 →
`rec.expireAt` 退化成「签发时刻 + 90 天」。而完整链恰恰是部署器最常
拿到的形态。改为只解析第一段(叶证书)。
顺带新增 `derLen()` / `parseSanFromDer()`,精确定位 SAN 扩展
OID `2.5.29.17` 再读 `[2] dNSName`,替掉原来的字节扫描启发式。
这条同时是「多吉云复用失效」的根因:判据缺到期时间,只看域名集合
就永远认为已覆盖 → 续期静默空转。修好后判据带上 `notAfter` 比对(1 天容差)。
5. DNS-01 挑战通知会撞 `400 authorization must be pending`(200181.xyz 连中两次)。
这是**竞态**不是逻辑错:「先读状态再 POST」挡不住毫秒级窗口。
已在 POST 侧做幂等容错(只认这一句),最终由 `pollAuthz` 定论。
三、Worker 退回只读
- `crons` 去掉 `10 4 * * *`,`index.ts` 里 renew 分支整体删除
- `POST /ssl/issue` 改 **501 硬拒绝**(而不是静默降级),响应给出国内机命令
- 签发 + 部署整条链路跑在国内机容器 `cn-certkeeper`
四、顺带修掉的两个「配置被悄悄抹掉」
- `configSave()` 不再丢掉表单不管理的 `probe_connect` / `probe_sni`。
之前管理员在面板改任何一项,这两个字段就会被清空,
后果是挂在 CDN 后的 t-t.live 探针退回公网、被误判成「还剩 80 多天」,
源站证书到期也不续。现在保存时从旧配置带过来。
五、新增 4 个常驻运维工具(deploy/cn-certkeeper/src/)
- `renew-one.mjs` 只对单个域名签发+部署(原 `/renew` 无域名过滤,会全量重签)
- `redeploy.mjs` 复用已签好的证书只重跑部署(不碰 ACME,不白烧配额)
- `rollback-dogecloud.mjs` 应急把 CDN 域名绑回指定证书 id
- `txt-inspect.mjs` `_acme-challenge` 下的 TXT 残留盘点/清理
- `selfcheck-acme.mjs` CA 层诊断:只读目录 + 复用账户,不签发不部署
六、测试与文档
- 自测新增 [11] 节 8 项 PEM 解析回归(样本是 openssl 现场生成、内联写死的
叶+中间证书,两段都以 `=` 结尾,正是 bug 现场),含精确值断言:
> 125 项通过,0 失败
- `npm run typecheck` 零错误
- 方案文档:§9.11 由「待验证」改为定案(`ssl/update` 不物化、
`ssl/upload+sslID` 才物化);新增 §9.12「本轮又修掉的 5 个静默失败」、
§9.13「本轮最终状态」、§9.14「certimate 工作流清查」
线上验收:三个域名(t-t.live / usj.cc / 200181.xyz)线上证书均为
LiteSSL ECC、2027-01-04 到期、daysLeft=90、needRenew=false;
1Panel 证书库 5 条精简为 3 条且全部在用;
certimate 停掉全部「会签发并部署」的工作流(团团 / 优世界 / 200181.xyz),
保留三条纯监控告警。
167 lines
9.3 KiB
TOML
167 lines
9.3 KiB
TOML
name = "artalk-cf"
|
||
main = "src/index.ts"
|
||
compatibility_date = "2026-07-24"
|
||
|
||
# ★ 证书探针需要 node:tls —— 只有它能在 Workers 里拿到**对端证书正文**
|
||
# (`cloudflare:sockets` 的 Socket 接口没有 getPeerCertificate,
|
||
# 实测线上只能确认「可达」但读不到 notAfter/SAN/issuer,那样证书管家等于瞎的)。
|
||
# 2026-08-04 之后的 compatibility_date 才默认带 nodejs_compat,
|
||
# 本项目定在 2026-07-24,所以必须显式打开这个 flag。
|
||
compatibility_flags = ["nodejs_compat"]
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ★ Workers Cache —— 缓存 Worker 自己生成的响应(无需回源 fetch)
|
||
#
|
||
# 为什么必须开:Worker 位于 zone 缓存之前("Workers sits in front of cache"),
|
||
# 所以 **zone 级 Cache Rules / Page Rules 对 Worker 响应完全无效**。
|
||
# 想让 /api/favicon 这类「Worker 直接返回」的响应被边缘缓存、从而不再执行
|
||
# Worker(省 KV 读 + 省 Worker 请求),唯一正解是这个 [cache] enabled。
|
||
#
|
||
# 生效条件(缺一不可):
|
||
# 1. 这里是 fetch 处理器(scheduled 等不参与缓存)
|
||
# 2. 响应带可缓存的 Cache-Control(本项目已带 s-maxage=86400)
|
||
# 3. 只缓存 GET/HEAD;带 Set-Cookie 的响应自动绕过
|
||
# 验证:连续两次 curl -I,应依次看到 cf-cache-status: MISS → HIT
|
||
# ---------------------------------------------------------------------------
|
||
[cache]
|
||
enabled = true
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# D1 (必须) 创建:npx wrangler d1 create artalk-cf
|
||
# 然后把返回的 database_id 填到下面
|
||
# ---------------------------------------------------------------------------
|
||
[[d1_databases]]
|
||
binding = "DB"
|
||
database_name = "artalk-cf"
|
||
database_id = "78140776-2bfd-439a-8b05-c3eebe5d5b47"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# KV —— RSS 订阅数据(rss-robot 合并进来;KV 与 D1 共存于同一 Worker,
|
||
# 数据沿用原 rss-robot-cf 的 namespace,零迁移)
|
||
# ---------------------------------------------------------------------------
|
||
[[kv_namespaces]]
|
||
binding = "RSS_KV"
|
||
id = "d7f86a0fb43f4450a10b786fc2635128"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 普通变量(可以明文写在仓库里)
|
||
# SITE_DEFAULT 默认站点名(博客里 site: '优世界',必须一致)
|
||
# SITE_URL 默认站点地址,用于把相对 page_key 拼成完整 page_url
|
||
# ADMIN_NAME/EMAIL 初始管理员(首次部署后调 /api/v2/setup 初始化)
|
||
# ALLOWED_ORIGINS CORS 白名单,逗号分隔;**必须包含你博客的域名**
|
||
# ---------------------------------------------------------------------------
|
||
[vars]
|
||
SITE_DEFAULT = "优世界"
|
||
SITE_URL = "https://usj.cc"
|
||
# 站内公开 API 基址(用于把 /api/favicon 这类相对地址拼成绝对地址,友圈页在博客域名下用)
|
||
PUBLIC_API_BASE = "https://api.200181.xyz"
|
||
ADMIN_NAME = "admin"
|
||
ADMIN_EMAIL = "admin@200181.xyz"
|
||
ALLOWED_ORIGINS = "https://usj.cc,https://www.usj.cc,https://post.usj.cc,https://artalk.200181.xyz,https://api.200181.xyz,http://localhost:1313,http://127.0.0.1:1313,http://localhost:8799,http://127.0.0.1:8799"
|
||
ITEMS_PER_PAGE = "20"
|
||
PENDING_DEFAULT = "false"
|
||
|
||
# ---- RSS 订阅(rss-robot 合并进来的变量)----
|
||
# SCF_PROXY_URL 腾讯云 SCF 国内代理(scfapi.usj.cc),抓国内博客用
|
||
# FEEDS_URL 远程订阅源 JSON(回退用;KV 里已有 feeds_config,留空避免 Worker 自调用)
|
||
# FEISHU_WEBHOOK_URL 飞书机器人 webhook(可选)
|
||
SCF_PROXY_URL = "https://scfapi.usj.cc"
|
||
FEEDS_URL = ""
|
||
FEISHU_WEBHOOK_URL = ""
|
||
# 文章编辑后端(自建 editor-api 容器,Worker 只做鉴权+反代)。
|
||
# 留空 = 模块自动禁用(/api/v2/editor/* 一律 503)。令牌另走 secret,别写这里。
|
||
# writeapi.usj.cc → 国内机 119.29.215.187 的 nginx 反代 → 127.0.0.1:8017 容器
|
||
# (换机时只改这条 + 新机重跑 deploy/editor-api/bootstrap.sh,域名指向新机即可)
|
||
EDITOR_API_BASE = "https://writeapi.usj.cc"
|
||
|
||
# ⚠️ 下面两个是机密,不要写在这里,用命令写入(详见「部署清单.md」第 4 步):
|
||
# npx wrangler secret put TOKEN_SECRET
|
||
# npx wrangler secret put ADMIN_PASSWORD
|
||
# 本地开发用 .dev.vars 文件(见 .dev.vars.example)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 邮件通知(可选)
|
||
#
|
||
# 为什么走 HTTP API 而不是 SMTP:**Cloudflare Workers 没有 TCP socket**,
|
||
# 根本连不上 SMTP 服务器。(Cloudflare 2026-04 推出的原生 Email Service
|
||
# 要 Workers Paid,本项目跑在免费版上。)
|
||
#
|
||
# 用 Resend:免费 3000 封/月(**每天上限 100 封**),本项目内置了日配额保护。
|
||
# 三个都走 secret / 环境变量:
|
||
# npx wrangler secret put RESEND_API_KEY # re_xxxxxxxx(Resend 后台创建)
|
||
# npx wrangler secret put MAIL_FROM # 形如 artalk <noreply@你的域名>
|
||
# npx wrangler secret put MAIL_ADMIN # 管理员收件邮箱;不配则不发管理员通知
|
||
#
|
||
# ⚠️ MAIL_FROM 里的域名必须先在 Resend 后台验证(加 SPF/DKIM 的 DNS 记录)。
|
||
# ⚠️ 三者任一缺失都只是「少发一类邮件」,不会影响评论提交 —— 邮件全程 try/catch。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# R2(可选)—— 评论里插图上传用。没建这个桶就自动禁用上传功能。
|
||
# 创建:npx wrangler r2 bucket create artalk-uploads
|
||
# ---------------------------------------------------------------------------
|
||
# [[r2_buckets]]
|
||
# binding = "UPLOADS"
|
||
# bucket_name = "artalk-uploads"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 静态资源(必须)
|
||
# public/sidebar/ = 官方管理后台(客户端用它打开 iframe:{server}/sidebar/)
|
||
# public/dist/ = 官方客户端与插件,博客若想直接从服务端引也可以用
|
||
# 来源:Artalk v2.8.7 的 artalk_ui.tar.gz(预构建,无需前端构建环境)
|
||
# ---------------------------------------------------------------------------
|
||
[assets]
|
||
directory = "public"
|
||
binding = "ASSETS"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 自定义域名
|
||
# 200181.xyz 的 NS 就在 Cloudflare 上,所以直接绑 artalk.200181.xyz 即可——
|
||
# custom_domain = true 会让 Cloudflare 自动建 DNS 记录 + 签 SSL 证书。
|
||
#
|
||
# 对比:usj.cc 的 NS 在 DNSPod,所以它不能这么绑(绑了会破坏又拍云/EdgeOne 调度)。
|
||
# ---------------------------------------------------------------------------
|
||
# ---------------------------------------------------------------------------
|
||
# 自定义域名
|
||
# 2026-10-03 起统一到 api.200181.xyz(评论 /api/v2/* + 订阅 /api/* 同一
|
||
# Worker),artalk.200181.xyz 已解绑废弃。usj.cc 的 NS 在 DNSPod 不能这样绑。
|
||
# ---------------------------------------------------------------------------
|
||
[[routes]]
|
||
pattern = "api.200181.xyz"
|
||
custom_domain = true
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 定时任务(合并 rss-robot 的抓取):
|
||
# 0 * * * * RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
|
||
# 17 3 * * * 评论 GC(限流/验证码清理 + healthz 缓存刷新)
|
||
#
|
||
# ★ 2026-10-06 起,证书续期 cron(原 `10 4 * * *`)已移除:
|
||
# 签发 + 部署整条链路搬到了国内机的 Docker 容器 `cn-certkeeper`
|
||
# (见 deploy/cn-certkeeper/、函数版证书管家-方案.md 第九章)。
|
||
# 两处同时跑会**重复签发**(白耗 CA 配额)并争抢同一批站点部署,
|
||
# 所以 Worker 侧只保留**只读**能力:证书状态查询 / 自检 / 后台。
|
||
# ---------------------------------------------------------------------------
|
||
[triggers]
|
||
crons = ["17 3 * * *", "0 * * * *"]
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 资源上限 —— ★ 免费版不支持,必须注释掉,否则**部署直接被拒**
|
||
#
|
||
# 2026-10-06 实测:wrangler deploy 报
|
||
# X [ERROR] A request to the Cloudflare API (.../workers/scripts/artalk-cf/versions)
|
||
# failed. CPU limits are not supported for the Free plan. [code: 100328]
|
||
# 注意:免费版不是「忽略」这个字段,而是**整个部署失败**,
|
||
# 所以只要账号还在 Free plan,这段就必须保持注释。
|
||
#
|
||
# ★ 免费版硬顶 CPU 10ms/请求(cron 同样 10ms)。实测一次签发的密码学
|
||
# 工作量:importKey 126µs + sign 84µs,一次签发约 10~12 次 JWS ——
|
||
# 优化前 ≈3.3ms、缓存 CryptoKey 后 ≈1.4ms;乘 2~3 倍保守系数后
|
||
# 是 6.6~10ms vs 2.8~4.2ms,属于**擦边**而非「必然爆」。
|
||
# 但既然国内机本来就在跑,把签发搬过去更划算(还顺带绕开了
|
||
# 「CF 出口 IP 进不了 1Panel 白名单」「Worker 拿不到证书正文」两个坑)。
|
||
# → 决策见 函数版证书管家-方案.md 第九章(B+C 组合,不买 Paid)。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
[observability]
|
||
enabled = true
|