Files
blog/blog-admin/src/index.ts
T
zqlit b821b78718 fix(ssl): 修掉 5 处静默失败,本项目全面接管签发部署,Worker 退回只读
一、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),
保留三条纯监控告警。
2026-10-06 20:08:28 +08:00

277 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Env } from './types';
import { Router } from './router';
import { corsHeaders } from './lib/util';
import { friendlyError } from './lib/errors';
import { gcCaptcha, gcRateLimits } from './lib/db';
import { checkProxyHealth, notifyProxyDown } from './lib/rss/proxy-health';
import { RssApi } from './rss-api';
import * as C from './routes/comments';
import * as P from './routes/public';
import * as U from './routes/user';
import * as A from './routes/admin';
import * as H from './routes/human';
import * as E from './routes/editor';
import * as S from './routes/ssl';
const router = new Router();
// ---------------------------------------------------------------- 公共 / 配置
router.all('/conf', P.getConf);
router.get('/version', P.getVersion);
router.get('/conf/domain', P.getConfDomain);
router.get('/conf/auth/providers', P.getAuthProviders);
router.get('/healthz', P.healthz);
router.post('/setup', P.setup);
// ---------------------------------------------------------------- 验证码
router.get('/captcha', P.getCaptcha);
router.get('/captcha/status', P.getCaptchaStatus);
router.post('/captcha/verify', P.verifyCaptcha);
// ------------------------------------------------- 人机验证(自研「一键验证」)
router.get('/human/challenge', H.humanChallenge);
router.post('/human/verify', H.humanVerify);
router.get('/human/status', H.humanStatus);
// ---------------------------------------------------------------- 文章编辑
// 本 Worker 不碰仓库:只是鉴权后用 X-Editor-Token 反代到 editor-api 容器。
// 必须挂在 /api/v2 下 —— /api/*(非 v2)已被上面的 RSS 模块整个接走。
router.get('/editor/health', E.health);
router.get('/editor/posts', E.listPosts);
router.post('/editor/posts', E.createPost);
router.get('/editor/posts/:slug', E.getPost);
router.put('/editor/posts/:slug', E.savePost);
router.del('/editor/posts/:slug', E.deletePost);
router.post('/editor/upload', E.upload);
router.get('/editor/git/status', E.gitStatus);
router.post('/editor/git/publish', E.publish);
router.post('/editor/git/sync', E.sync);
router.get('/editor/handoff', E.handoff);
// ---------------------------------------------------------------- SSL 证书管家
// 与 artalk-cf 同一个 Worker,共用后台会话与 KV。鉴权在自己的模块里
// (只认 Bearer 会话,放行 admin + ssl 两种角色),这里只负责挂路径。
// ★ 必须挂在 /api/v2 下 —— /api/*(非 v2)已被 RSS 模块整个接走。
for (const r of S.SSL_ROUTES) {
if (r.method === 'GET') router.get(r.path, r.handler);
else if (r.method === 'POST') router.post(r.path, r.handler);
else if (r.method === 'PUT') router.put(r.path, r.handler);
else router.del(r.path, r.handler);
}
// ---------------------------------------------------------------- 评论
// ★ /comments/lookup 必须注册在 /comments/:id 之前 —— 路由按注册顺序匹配,
// 两段路径下 `:id` 会把 "lookup" 吃掉。
router.get('/comments', C.listComments);
router.get('/comments/lookup', C.lookupCommenter);
router.post('/comments', C.createComment);
router.get('/comments/:id', C.getComment);
router.put('/comments/:id', C.updateComment);
router.del('/comments/:id', C.deleteComment);
router.get('/featured/comments', C.listFeaturedComments);
// ---------------------------------------------------------------- 投票 / 浏览量
// ★ 投票:Artalk 客户端把「方向」编进 target_name(comment_up / comment_down /
// page_up / page_down),路径只有两段 → `POST /votes/:target_name/:target_id`。
// 早期这里只注册了三段形式(:choice),与客户端对不上,导致点赞点了 404 没反应。
// 现在两段(客户端契约)与三段(旧形式)都收。
router.get('/votes/:target_name/:target_id', P.voteGet);
router.post('/votes/:target_name/:target_id', P.voteCreate);
router.post('/votes/:target_name/:target_id/:choice', P.voteCreate);
router.post('/votes/sync', P.voteSync);
router.post('/pages/pv', P.pagePV);
// ---------------------------------------------------------------- 用户 / 登录
router.get('/user', U.userInfo);
router.post('/user', U.userUpdate);
router.get('/user/status', U.userStatus);
router.post('/user/access_token', U.userAccessToken);
router.post('/auth/email/login', U.authEmailLogin);
router.post('/auth/email/send', U.authEmailSend);
router.post('/auth/email/register', U.authEmailRegister);
router.get('/auth/merge', U.authMergeCheck);
router.post('/auth/merge', U.authMergeApply);
router.post('/sso/exchange', U.ssoExchange);
// ---------------------------------------------------------------- 后台:设置
router.get('/settings', A.settingGet);
router.put('/settings', A.settingApply);
router.get('/settings/form', A.settingFormGet);
router.put('/settings/form', A.settingFormApply);
router.get('/settings/template/:locale', A.settingTemplate);
// ---------------------------------------------------------------- 后台:站点
router.get('/sites', A.siteList);
router.post('/sites', A.siteCreate);
router.put('/sites/:id', A.siteUpdate);
router.del('/sites/:id', A.siteDelete);
// ---------------------------------------------------------------- 后台:页面
router.get('/pages', A.pageList);
router.post('/pages/fetch', A.pageFetch);
router.get('/pages/fetch/status', A.pageFetchStatus);
router.post('/pages/:id/fetch', A.pageFetch);
router.put('/pages/:id', A.pageUpdate);
router.del('/pages/:id', A.pageDelete);
// ---------------------------------------------------------------- 后台:用户
router.post('/users', A.userCreate);
router.get('/users/:type', A.userList);
router.put('/users/:id', A.userUpdateAdmin);
router.del('/users/:id', A.userDeleteAdmin);
// ---------------------------------------------------------------- 后台:消息 / 统计 / 缓存
router.get('/notifies', A.notifyList);
router.post('/notifies/read', A.notifyReadAll);
router.post('/notifies/:comment_id/:notify_key', A.notifyReadOne);
router.get('/stats/:type', A.stat);
router.post('/cache/flush', A.cacheFlush);
router.post('/cache/warm_up', A.cacheWarmUp);
// ---------------------------------------------------------------- 后台:上传 / 迁移
router.post('/upload', A.upload);
router.get('/transfer/export', A.transferExport);
router.post('/transfer/import', A.transferImport);
router.post('/transfer/upload', A.transferUpload);
// ---------------------------------------------------------------- 请求分发
async function dispatchRequest(
req: Request,
env: Env,
ctx: ExecutionContext,
url: URL,
): Promise<Response> {
// R2 里的图片:/file/** 或 /api/v2/file/**
if (req.method === 'GET' && /^(\/api\/v2)?\/file\//.test(url.pathname)) {
return A.serveFile({
env,
req,
url,
params: {},
user: null,
});
}
// RSS 订阅模块(rss-robot 合并进来):/api/*(Artalk 用 /api/v2/* 不会撞)
// + /feed 订阅源管理页 + 微信验证文件
const p = url.pathname;
if (
(p.startsWith('/api/') && !p.startsWith('/api/v2')) ||
p === '/feed' ||
p.startsWith('/MP_verify_')
) {
return RssApi.fetch(req, env, ctx);
}
return router.dispatch(req, env, ctx);
}
// ---------------------------------------------------------------- 缓存策略兜底
//
// ★ 为什么需要这个:Worker 开启了 Workers Cache(wrangler.toml 的 [cache]
// enabled = true)之后,Cloudflare 会在**调用 Worker 之前**先查缓存。
// 而 Workers Cache 遵循 RFC 9111 —— 包括**启发式缓存**:一个完全不带
// Cache-Control 的响应也可能被判为可缓存!
//
// 实测教训:`/api/v2/comments` 本来就没设 Cache-Control,开启 Workers Cache
// 后立刻被缓存(cf-cache-status: HIT, age: 29)→ 用户发的新评论看不见。
//
// 规则(白名单式,只放行明确声明可缓存的):
// - 上游已带任意 Cache-Control / CDN-Cache-Control → 原样保留(如 favicon 的
// s-maxage=86400,它本来就该缓存)
// - 其余一律补 `Cache-Control: no-store`(评论、登录、管理接口等禁止缓存)
//
// ★ 必须是白名单思路:漏掉一个接口 = 数据陈旧 bug;而多补 no-store 只损失一点性能。
function ensureCachePolicy(res: Response, req: Request): Response {
// 只处理 GET/HEAD(Workers Cache 本就只缓存这两种;其余原样返回)
if (req.method !== 'GET' && req.method !== 'HEAD') return res;
const hasExplicitPolicy =
res.headers.has('Cache-Control') ||
res.headers.has('CDN-Cache-Control') ||
res.headers.has('Cloudflare-CDN-Cache-Control');
if (hasExplicitPolicy) return res;
// 其余(含 3xx / 5xx)一律禁止缓存:宁可少缓存,不可把数据或错误固化
const headers = new Headers(res.headers);
headers.set('Cache-Control', 'no-store');
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
try {
const res = await dispatchRequest(req, env, ctx, url);
return ensureCachePolicy(res, req);
} catch (e) {
// 兜底:任何漏网的异常也必须带 CORS 头,否则浏览器把它当"网络故障",
// 读者只能看到 "TypeError: Failed to fetch",根本不知道发生了什么。
const err = friendlyError(e);
console.error('[artalk-cf] unhandled:', err.code, err.detail);
let cors = new Headers();
try {
cors = corsHeaders(req, env);
} catch {
cors.set('Access-Control-Allow-Origin', '*');
}
cors.set('Content-Type', 'application/json; charset=utf-8');
cors.set('Cache-Control', 'no-store');
return new Response(
JSON.stringify({ msg: err.msg, code: err.code, detail: err.detail }),
{ status: err.status, headers: cors },
);
}
},
// 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
// 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存)
//
// ★ 2026-10-06:`10 4 * * *` 的**证书续期检查已移除** —— 签发+部署整条链路
// 搬到了国内机的 Docker 容器 `cn-certkeeper`(见 deploy/cn-certkeeper/)。
// 两边同时跑会重复签发(白耗 CA 配额)并争抢同一批站点部署。
// Worker 侧只保留**只读**能力:证书状态 / 自检 / 后台展示。
async scheduled(event: ScheduledController, env: Env): Promise<void> {
if (event.cron === '0 * * * *') {
await RssApi.scheduled(event, env, undefined as unknown as ExecutionContext);
return;
}
try {
await gcRateLimits(env);
await gcCaptcha(env);
// 顺带刷新 healthz 用的评论数缓存:COUNT(*) 是全表扫描,
// 放在 healthz 里每次请求都烧几千行 rows_read,挪到这里一天只烧一次
const n = await env.DB.prepare('SELECT COUNT(*) AS n FROM comments WHERE deleted_at = 0')
.first<{ n: number }>();
if (n?.n != null) {
await env.DB.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES ('healthz_comments', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.bind(JSON.stringify(n.n), Date.now())
.run();
}
} catch (e) {
console.error('[artalk-cf] scheduled gc failed:', e instanceof Error ? e.message : e);
}
// 国内代理体检(每天一次):挂掉就给站长发提醒邮件。
// 刻意放在服务器端 —— 站长电脑关着也能收到,不依赖本地定时任务。
try {
const health = await checkProxyHealth(env);
if (!health.ok) {
const r = await notifyProxyDown(env, health);
console.log('[artalk-cf] proxy down, notify:', JSON.stringify(r));
}
} catch (e) {
console.error('[artalk-cf] proxy health check failed:', e instanceof Error ? e.message : e);
}
},
};