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
@@ -1979,7 +1979,7 @@ async function viewSSL() {
|
||||
'<button class="btn tiny" data-act="ssl-check"><span>立即检查</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-selfcheck"><span>环境自检</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-renew-check"><span>该续期了吗</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-issue-all"><span>签发/续期</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-issue-all"><span>签发/续期(在国内机)</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-notify"><span>发提醒邮件</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-add-domain"><span>+ 域名</span></button>' +
|
||||
(ov.mailEnabled ? '' : '<span class="tag warn">邮件未配置</span>')) +
|
||||
@@ -2586,31 +2586,25 @@ document.addEventListener('click', async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 真正签发(慢,1~3 分钟/域名):必须明确告知用户「正在跑,别关页面」
|
||||
// ★ 2026-10-06:签发/续期已**迁到国内机容器 cn-certkeeper**,Worker 侧只留只读。
|
||||
// 这里不再调 /issue(后端也返回 501),改为给出可直接复制的操作指引。
|
||||
// 为什么迁:免费版 CPU 硬顶 10ms(签发擦边)、CF 出口 IP 进不了 1Panel
|
||||
// 白名单、Worker 拿不到对端证书正文。详见 函数版证书管家-方案.md 第九章。
|
||||
if (act === 'ssl-issue' || act === 'ssl-issue-all') {
|
||||
const domain = act === 'ssl-issue' ? el.dataset.domain : '';
|
||||
const label = domain || '全部启用的域名';
|
||||
confirmBox('签发证书:' + label,
|
||||
'会真的向 CA 申请一张新证书,然后部署到配置里的目标(多吉云 / 1Panel)。' +
|
||||
'每个域名大约 1~3 分钟(要等 DNS 记录生效)。期间请不要关闭页面。确定开始?',
|
||||
async () => {
|
||||
el.dataset.busy = '1';
|
||||
const old = el.innerHTML;
|
||||
el.innerHTML = '<span>签发中…</span>';
|
||||
try {
|
||||
const r = await sslApi('/issue', { method: 'POST', body: { domain, force: true } });
|
||||
const list = r.results || [];
|
||||
const bad = list.filter((x) => !x.ok);
|
||||
if (!list.length) toast('没有可签发的域名');
|
||||
else if (bad.length) toast('⚠ ' + bad.length + '/' + list.length + ' 失败:' + bad.map((x) => x.domain + ' ' + x.reason).join(';'), true);
|
||||
else toast('✓ ' + list.map((x) => x.domain + ' ' + x.reason).join(';'));
|
||||
await viewSSL();
|
||||
} catch (ex) {
|
||||
toast('✕ ' + ex.message, true);
|
||||
el.innerHTML = old;
|
||||
delete el.dataset.busy;
|
||||
}
|
||||
});
|
||||
const cmd = 'curl -s -X POST -H "X-Auth-Token: $AUTH_TOKEN" http://127.0.0.1:8019/renew';
|
||||
modal('签发/续期已迁到国内机:' + label,
|
||||
'<p style="margin-top:0">签发 + 部署整条链路现在跑在<b>国内机的 Docker 容器 cn-certkeeper</b>' +
|
||||
'(只绑 <code>127.0.0.1</code>,不对外)。这个页面只做<b>只读监控与配置</b>。</p>' +
|
||||
'<p>在内侧机上执行:</p>' +
|
||||
'<pre style="white-space:pre-wrap;word-break:break-all;background:rgba(127,127,127,.12);padding:10px;border-radius:8px">' +
|
||||
esc(cmd) + '</pre>' +
|
||||
'<p style="margin-bottom:0">· 只问「该不该续」:<code>GET /renew-check</code><br>' +
|
||||
'· 看凭据是否齐备:<code>GET /preflight</code><br>' +
|
||||
'· 看每个域名的真实剩余天数:<code>GET /status</code></p>' +
|
||||
'<div style="display:flex;justify-content:flex-end;margin-top:18px">' +
|
||||
'<button class="btn" data-act="modal-close"><span>知道了</span></button></div>');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user