Files
blog/blog-admin/tools/selftest-csr.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

72 lines
3.3 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.
/**
* CSR 离线自测 —— 手写 DER 的 CSR 到底合不合法。
*
* 为什么必须有(2026-10-06 踩坑):
* LiteSSL 的 `finalize` 对着一张有问题的 CSR 只回了
* `500 {"type":"urn:ietf:params:acme:error:serverInternal",
* "detail":"The server experienced an internal error"}`
* —— 完全指不到「CSR 结构错了」这个方向。而 CSR 是本项目**手写 DER**
* 拼出来的(不引 asn1.js / pkijs),出错概率天然比用现成库高。
* 这里用 openssl 逐项核对:签名自洽、subject CN、SAN 列表。
*
* 用法:
* node tools/selftest-csr.mjs # 默认签 t-t.live + *.t-t.live
* node tools/selftest-csr.mjs a.com b.com
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { makeCsrForTest } = require(path.resolve(here, '../.selftest-ssl/lib/acme.js'));
const domains = process.argv.slice(2).length ? process.argv.slice(2) : ['t-t.live', '*.t-t.live'];
const kp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const der = await makeCsrForTest(kp, domains);
const out = path.join(os.tmpdir(), `csr-selftest-${Date.now()}.der`);
fs.writeFileSync(out, Buffer.from(der));
console.log(`生成 CSR:domains=${domains.join(', ')} DER ${der.byteLength} 字节`);
console.log(`临时文件:${out}\n`);
const run = (...args) => {
try {
return { ok: true, out: execFileSync('openssl', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) };
} catch (e) {
return { ok: false, out: `${e.stdout || ''}${e.stderr || ''}` };
}
};
// ① 签名自洽:openssl 用 CSR 里的公钥验 CSR 里的签名
const v = run('req', '-inform', 'DER', '-in', out, '-noout', '-verify');
console.log('① 签名自洽:', v.ok ? '✓ ' + v.out.trim().replace(/^.*?:\s*/, '') : '✗\n' + v.out);
// ② 结构 + subject + SAN
const t = run('req', '-inform', 'DER', '-in', out, '-noout', '-text');
if (!t.ok) {
console.log('② ★ 结构解析失败(这就是 CA 报 500 的原因):\n' + t.out);
process.exit(1);
}
const subject = (t.out.match(/Subject:\s*(.*)/) || [])[1] || '';
const sanBlock = (t.out.match(/X509v3 Subject Alternative Name:[\s\S]*?\n\s{8}([^\n]+)/) || [])[1] || '';
console.log('② subject =', subject.trim());
console.log(' SAN =', sanBlock.trim());
console.log(' 签名算法 =', ((t.out.match(/Signature Algorithm:\s*(.*)/) || [])[1] || '').trim());
console.log(' 公钥 =', ((t.out.match(/Public Key Algorithm:\s*(.*)/) || [])[1] || '').trim(),
((t.out.match(/NIST CURVE:\s*(.*)/) || [])[1] || '').trim());
// ③ 逐条比对域名(顺序 + 内容都要一致)
const got = sanBlock.split(',').map((s) => s.trim().replace(/^DNS:/, '')).filter(Boolean);
const want = domains.slice();
const same = got.length === want.length && want.every((d, i) => got[i] === d);
console.log('③ SAN 与请求一致:', same ? '✓' : `✗ 期望 [${want}] 实得 [${got}]`);
fs.unlinkSync(out);
console.log('\n结论:', v.ok && same ? 'CSR 合法 ✓' : '★ CSR 有问题,别拿去打 CA');
process.exit(v.ok && same ? 0 : 1);