Files
blog/deploy/cn-certkeeper/src/renew-one.mjs
T
zqlit 740d77e4cb 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

76 lines
3.1 KiB
JavaScript
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.
/**
* 只对**单个域名**做「签发 + 部署」,别的域名一律不碰。
*
* 为什么需要它(2026-10-06 加):
* 容器只有一个 `POST /renew`,而它调的是 `renewAll` —— **没有域名过滤**。
* 想单独把一个域名提前换掉时,`{force:true}` 会把配置里**所有**域名
* 一起重签一遍:多烧 2 个 CA 订单,还会把本来好好的 t-t.live / 200181.xyz
* 一起换掉、增加无关的线上变更面。这个脚本把「只动一个域名」变成可选操作。
*
* 与 `redeploy.mjs` 的分工:
* · `redeploy.mjs` —— **复用已签好的证书**,只重跑部署(不碰 ACME)
* · `renew-one.mjs` —— 真的去 CA **签一张新的**,然后部署
*
* 用法:
* docker exec cn-certkeeper node src/renew-one.mjs usj.cc
* docker exec cn-certkeeper node src/renew-one.mjs usj.cc --no-deploy # 只签不部署
* docker exec cn-certkeeper node src/renew-one.mjs usj.cc --dry # 只看会做什么
*
* ★ 默认 `force: true`(线上还剩 60 天也要换)—— 这正是本脚本的用途。
* 想走「按阈值判断」请直接调 /renew。
*/
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { FileKV } from './kv-file.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`));
const argv = process.argv.slice(2);
const NAME = argv.find((a) => !a.startsWith('--'));
const DRY = argv.includes('--dry');
const NO_DEPLOY = argv.includes('--no-deploy');
if (!NAME) {
console.error('用法:node src/renew-one.mjs <域名> [--no-deploy] [--dry]');
process.exit(2);
}
const kv = new FileKV(process.env.DATA_DIR || '/data');
const env = {
RSS_KV: kv,
TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(),
// 留空 → 探针直接走本机 node:tls,不再绕 editor-api
EDITOR_API_BASE: '',
EDITOR_TOKEN: '',
};
const { loadConfig } = lib('certstore');
const { issueDomain, RENEW_BEFORE_DAYS } = lib('certissue');
const cfg = await loadConfig(env);
const d = cfg.domains.find((x) => x.name === NAME);
if (!d) {
console.error(`配置里没有域名「${NAME}」(现有:${cfg.domains.map((x) => x.name).join(', ')})`);
process.exit(2);
}
console.log(`域名 ${NAME}`);
console.log(` SAN : ${(d.san || []).join(', ')}`);
console.log(` DNS 凭据 : ${d.dns}`);
console.log(` 部署目标 : ${(d.deploy || []).join(', ')}`);
console.log(` 1Panel 站点: ${(d.one_panel_sites || []).join(', ')}`);
console.log(` 多吉云域名 : ${(d.dogecloud_domains || []).join(', ')}`);
console.log(` 续期阈值 : ${RENEW_BEFORE_DAYS} 天(本脚本 force,忽略阈值)`);
console.log(` 部署 : ${NO_DEPLOY ? '否(--no-deploy)' : '是'}`);
if (DRY) {
console.log('\n[dry] 不实际签发。');
process.exit(0);
}
const out = await issueDomain(env, d, { force: true, noDeploy: NO_DEPLOY, by: 'renew-one' });
console.log('\n=== 结果 ===');
console.log(JSON.stringify(out, null, 2));
if (!out.ok) process.exitCode = 1;