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),
保留三条纯监控告警。
This commit is contained in:
1 parent
1427c47dcf
commit
740d77e4cb
20 files changed
+1662
-177
No files matched your search
@@ -33,6 +33,11 @@ export const CONFIG = {
|
||||
'openwrt.usj.cc',
|
||||
'vw.usj.cc', // vaultwarden(alias 才是这个名字)
|
||||
],
|
||||
// ★ 源站直连探针:usj.cc 走多吉云 CDN,公网握手拿到的是 CDN 边缘证书;
|
||||
// 要续的是本机 1Panel 上那张。面板里没有 usj.cc 同名站点,
|
||||
// 所以 SNI 指到真正引用该证书的 artalk.usj.cc。
|
||||
probe_connect: '119.29.215.187',
|
||||
probe_sni: 'artalk.usj.cc',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
@@ -41,6 +46,12 @@ export const CONFIG = {
|
||||
dns: 'tencent-tt',
|
||||
deploy: ['1panel'],
|
||||
one_panel_sites: ['t-t.live', 'www.t-t.live', 'pl.t-t.live', 'pwd.t-t.live', 'certd.t-t.live'],
|
||||
// ★★ t-t.live 的公网入口是**腾讯云 EO 边缘加速**(apex 是一条 CNAME →
|
||||
// t-t.live.eo.dnse2.com),公网握手量到的是 EO 的边缘证书(LE,12-27),
|
||||
// 而我们要续的是本机 nginx 上那张源站证书(曾停在 07-11、10-09 到期)。
|
||||
// 不连源站 IP 的话,续期判定会永远「还剩 80 多天」→ 源站证书悄悄过期。
|
||||
probe_connect: '119.29.215.187',
|
||||
probe_sni: 't-t.live',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
@@ -49,6 +60,10 @@ export const CONFIG = {
|
||||
dns: 'cloudflare',
|
||||
deploy: ['1panel'],
|
||||
one_panel_sites: ['ssh.200181.xyz'],
|
||||
// 同理:200181.xyz 的 NS 在 Cloudflare(带代理),公网拿到的是 CF 边缘证书。
|
||||
// 面板上的站点名是 ssh.200181.xyz,用它当 SNI。
|
||||
probe_connect: '119.29.215.187',
|
||||
probe_sni: 'ssh.200181.xyz',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -86,6 +86,7 @@ const ssl = await import(toFileUrl(path.join(outDir, 'routes', 'ssl.js')));
|
||||
const store = await import(toFileUrl(path.join(outDir, 'lib', 'certstore.js')));
|
||||
const vault = await import(toFileUrl(path.join(outDir, 'lib', 'certvault.js')));
|
||||
const role = await import(toFileUrl(path.join(outDir, 'lib', 'role.js')));
|
||||
const probe = await import(toFileUrl(path.join(outDir, 'lib', 'certprobe.js')));
|
||||
|
||||
/** 造一个 Ctx */
|
||||
function ctx({ method = 'GET', url = 'https://api.200181.xyz/api/v2/ssl/x', user = null, body, origin } = {}) {
|
||||
@@ -463,6 +464,47 @@ console.log('\n[10] 提醒邮件 HTML 转义');
|
||||
t('邮件含「已过期」文案', html.includes('已过期'));
|
||||
}
|
||||
|
||||
// ================================================================ 11. PEM 解析
|
||||
console.log('\n[11] parsePemInfo(叶证书 vs 完整链)');
|
||||
{
|
||||
// 样本是测试期用 openssl 现场生成的两张自签名 EC P-256 证书(固定内容,写死在这里):
|
||||
// · LEAF —— CN=leaf.test.example,SAN 两条,notAfter = 2027-01-04T11:49:46Z
|
||||
// (用 `openssl x509 -enddate` 核对过)
|
||||
// · INTER —— CN=Test Intermediate CA,notAfter 10 年后
|
||||
// ★ 两段的 base64 都**以 `=` 结尾** —— 这正是 2026-10-06 那个 bug 的要害:
|
||||
// 旧实现把两段 base64 直接拼起来再 atob,中间夹着的 `=` 让它抛错并返回 {},
|
||||
// 于是调用方静默拿到 notAfter=undefined,多吉云的「已有证书够不够新」比对
|
||||
// 永远为假、续期每次都白传一张新证书。
|
||||
const LEAF = [
|
||||
'MIIBwzCCAWigAwIBAgIUcRvLNWnNvGcdn8d/ItEBgEayxGAwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRbGVhZi50ZXN0LmV4YW1wbGUwHhcNMjYxMDA2MTE0OTQ2WhcNMjcwMTA0MTE0OTQ2WjAcMRowGAYDVQQDDBFsZWFmLnRlc3QuZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDX4LLFxjz8KNv99ZGBC+4nqmB2Xiy99QJQjdoPCrUJ0QBQBoiqCej6GNMv95YHhHHmt1G8UoLUQVIcov50XEMmjgYcwgYQwHQYDVR0OBBYEFA8Vyep8ig9Jz74fE6V+AATEjoZCMB8GA1UdIwQYMBaAFA8Vyep8ig9Jz74fE6V+AATEjoZCMA8GA1UdEwEB/wQFMAMBAf8wMQYDVR0RBCowKIIRbGVhZi50ZXN0LmV4YW1wbGWCEyoubGVhZi50ZXN0LmV4YW1wbGUwCgYIKoZIzj0EAwIDSQAwRgIhAIYiU4/+Nq3040H048wAZspmjEre0OARL/Q3lPWHM7QeAiEAp4T10yOpim1yuWMWUvtfpZFpcbQ06hY2QF3BGUC1BJk=',
|
||||
].join('');
|
||||
const INTER = [
|
||||
'MIIBkzCCATmgAwIBAgIUeQUqWUR70p3dpsmPFDMnnNTtGegwCgYIKoZIzj0EAwIwHzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwHhcNMjYxMDA2MTE0OTQ2WhcNMzYxMDAzMTE0OTQ2WjAfMR0wGwYDVQQDDBRUZXN0IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPQR/JXD6rfVFjw/7irBNHZDEPC0kDTY12k3EEN0WdLm9f96sFz/vnYpBK9VOhGmCj7KwUtnmdTk3D4rgTZyOh2jUzBRMB0GA1UdDgQWBBRxPnrfHxRLS/9e1V04zolxn56XLDAfBgNVHSMEGDAWgBRxPnrfHxRLS/9e1V04zolxn56XLDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIH9r1Gm8GnYgqGb4QVAfivYdtdq0FLt67nvIcrY9a0XkAiEAl3MH0wOcns5wAclTlJ5cg2/nJ5LenoTe6mDQglgNubg=',
|
||||
].join('');
|
||||
const wrap = (b) => `-----BEGIN CERTIFICATE-----\n${b}\n-----END CERTIFICATE-----\n`;
|
||||
const leafPem = wrap(LEAF);
|
||||
const chainPem = leafPem + wrap(INTER);
|
||||
|
||||
const EXPECT = Date.UTC(2027, 0, 4, 11, 49, 46); // openssl x509 -enddate 核对过
|
||||
|
||||
const leaf = probe.parsePemInfo(leafPem);
|
||||
const chain = probe.parsePemInfo(chainPem);
|
||||
|
||||
// ① 关键回归:多段链必须能解析出 notAfter(旧实现在这里返回 {})
|
||||
t('★ 完整链也能解析出 notAfter(旧实现返回 {})', typeof chain.notAfter === 'number');
|
||||
eq('★ 完整链的 notAfter 精确等于叶证书 notAfter', chain.notAfter, EXPECT);
|
||||
eq('单段与完整链的 notAfter 一致', leaf.notAfter, chain.notAfter);
|
||||
|
||||
// ② 只认叶证书的 SAN,不要把中间证书的名字带进来
|
||||
eq('SAN 精确解析(叶证书两条)', JSON.stringify(chain.altNames), JSON.stringify(['leaf.test.example', '*.leaf.test.example']));
|
||||
t('★ 链解析不混入中间证书主体名', !(chain.altNames || []).some((n) => /intermediate/i.test(n)));
|
||||
|
||||
// ③ 非法输入不抛错、返回不完整对象
|
||||
t('空串 → 没 notAfter', probe.parsePemInfo('').notAfter === undefined);
|
||||
t('非 PEM → 没 notAfter', probe.parsePemInfo('hello world').notAfter === undefined);
|
||||
t('只写 BEGIN 没有 END → 没 notAfter', probe.parsePemInfo('-----BEGIN CERTIFICATE-----\nAAAA\n').notAfter === undefined);
|
||||
}
|
||||
|
||||
// ================================================================ 汇总
|
||||
console.log('\n' + '='.repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
@@ -472,4 +514,14 @@ if (fail) {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('全部通过 ✓');
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
|
||||
// ★ 这里**故意不再删 `.selftest-ssl/`**(2026-10-06 改)。
|
||||
//
|
||||
// 原来结尾有一句 `fs.rmSync(outDir)`,本意是「别把测试产物留在树里」。
|
||||
// 但 `deploy/cn-certkeeper/` 的构建流程恰恰要用这份产物:
|
||||
// npm run selftest:ssl:build
|
||||
// cp -r blog-admin/.selftest-ssl/lib deploy/cn-certkeeper/lib
|
||||
// 于是「跑完自测」= 「产物被删」= 后面的 cp 直接失败(报错还算温和),
|
||||
// 更糟的是有人 cp 到一半就跳过 → **镜像里打进一份陈旧的 acme.js**,
|
||||
// 排查时会以为「改了代码没生效」。构建产物本来就已 gitignore,留着无害。
|
||||
console.log(`(编译产物保留在 ${path.relative(process.cwd(), outDir)}/,cn-certkeeper 要用)`);
|
||||
Reference in new issue
Block a user