一、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),
保留三条纯监控告警。
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
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);
|
||
}
|
||
},
|
||
};
|