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:
zqlit committed 2026-10-06 20:08:28 +08:00
1 parent 1427c47dcf
commit 740d77e4cb
20 files changed
+1662 -177

No files matched your search

+39 -21
View File
@@ -42,7 +42,7 @@ import {
type KeeperConfig,
} from '../lib/certstore';
import { daysLeft, parsePemInfo, probeTls } from '../lib/certprobe';
import { issueDomain, RENEW_BEFORE_DAYS, type IssueOutcome } from '../lib/certissue';
import { RENEW_BEFORE_DAYS } from '../lib/certissue';
import { AcmeClient } from '../lib/acme';
import { makeDnsProvider } from '../lib/dnsprovider';
import { makeDeployer, OnePanelDeployer } from '../lib/deployer';
@@ -215,6 +215,14 @@ export async function configSave(ctx: Ctx): Promise<Response> {
const seen = new Set<string>();
const domains: DomainConfig[] = [];
// ★ 后台的域名表单**不管理** `probe_connect` / `probe_sni`(它们是给运维用的
// 源站探针开关)。保存时把旧值带过来,否则管理员在面板上改一次别的东西,
// 这两个字段就被悄悄抹掉了 —— 后果是 t-t.live 这类挂在 CDN 后的域名
// 探针退回公网、被误判成「还剩 80 多天」,源站证书到期也不续。
const prev = await loadConfig(ctx.env);
const prevByName = new Map(prev.domains.map((d) => [d.name, d]));
for (const [i, d] of domainsRaw.entries()) {
const name = String(d?.name || '').trim().toLowerCase();
if (!name) return fail(400, `第 ${i + 1} 个域名缺少 name`);
@@ -236,6 +244,7 @@ export async function configSave(ctx: Ctx): Promise<Response> {
if (!DEPLOY_TARGETS.includes(t)) return fail(400, `不认识的部署目标「${t}」`);
}
const old = prevByName.get(name);
domains.push({
name,
san,
@@ -243,6 +252,13 @@ export async function configSave(ctx: Ctx): Promise<Response> {
deploy,
dogecloud_domains: Array.isArray(d?.dogecloud_domains) ? d.dogecloud_domains.map(String).filter(Boolean) : [],
one_panel_sites: Array.isArray(d?.one_panel_sites) ? d.one_panel_sites.map(String).filter(Boolean) : [],
// 表单不管这两项 → 沿用旧值(见上面 prevByName 的注释)
...((d?.probe_connect ?? old?.probe_connect)
? { probe_connect: String(d?.probe_connect ?? old?.probe_connect).trim() }
: {}),
...((d?.probe_sni ?? old?.probe_sni)
? { probe_sni: String(d?.probe_sni ?? old?.probe_sni).trim().toLowerCase() }
: {}),
...(d?.disabled ? { disabled: true } : {}),
});
}
@@ -486,8 +502,8 @@ export async function certCheck(ctx: Ctx): Promise<Response> {
for (const d of targets) {
const rec = await getCert(ctx.env, d.name);
const storedLeft = daysLeft(rec?.expireAt);
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
const host = d.probe_sni || d.san.find((s) => !s.startsWith('*.')) || d.name;
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN, d.probe_connect);
let verdict = 'unknown';
if (!live.ok) verdict = 'unreachable';
@@ -611,22 +627,22 @@ export async function certIssue(ctx: Ctx): Promise<Response> {
if ('deny' in w) return w.deny;
const body = w.body as { domain?: string; force?: boolean; noDeploy?: boolean };
const cfg = await loadConfig(ctx.env);
const only = String(body.domain || '').trim();
const targets = cfg.domains.filter((d) => !only || d.name === only);
if (!targets.length) return fail(400, only ? `配置里没有域名「${only}」` : '配置里还没有域名');
await log(ctx, w.ident, 'issue', `${only || '全部域名'}:开始${body.force ? '强制' : ''}签发`, 'info', only);
const results: IssueOutcome[] = [];
for (const d of targets) {
const r = await issueDomain(ctx.env, d, {
force: !!body.force,
noDeploy: !!body.noDeploy,
by: w.ident.name || '管理员',
});
results.push(r);
}
return ok({ results });
// ★★ 2026-10-06:Worker 侧**不再承担签发**,这里明确拒绝(501)。
//
// 为什么不是「留着,跑失败再说」:
// · 免费版 CPU 硬顶 10ms,签发属于擦边(实测优化后 2.8~4.2ms,但没余量);
// · CF 出口 IP 是海量动态段,进不了 1Panel 的 API 白名单 → 部署必失败;
// · Worker 运行时**拿不到对端证书正文** → 探针判不准要不要签。
// 整条“探针 → 签发 → 部署”链路已搬到国内机的 Docker 容器 `cn-certkeeper`
// (deploy/cn-certkeeper/,见 函数版证书管家-方案.md 第九章)。
// 两边同时签发会**重复下单**(白耗 CA 配额)并争抢同一批站点部署,
// 所以这里是硬拒绝,而不是静默降级。
await log(ctx, w.ident, 'issue', `拒绝签发请求(已迁至国内机):${body.domain || '全部域名'}`, 'warn', body.domain);
return fail(
501,
'证书签发已迁移到国内机容器 cn-certkeeper(Worker 只留只读监控)。' +
'请在服务器上执行:curl -s -X POST -H "X-Auth-Token: <AUTH_TOKEN>" http://127.0.0.1:8019/renew',
);
}
/** 只看「该不该续期」,不签发 —— 给 UI 的「检查」按钮用,秒回 */
@@ -641,8 +657,10 @@ export async function certRenewCheck(ctx: Ctx): Promise<Response> {
continue;
}
const rec = await getCert(ctx.env, d.name);
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
// ★ 用 probe_sni / probe_connect 与国内机保持一致:域名挂在 CDN/边缘
// 加速后面时,公网握手量到的是**边缘证书**,判「还剩多少天」会失真。
const host = d.probe_sni || d.san.find((s) => !s.startsWith('*.')) || d.name;
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN, d.probe_connect);
const left = daysLeft(live.notAfter);
// ★ 判定「线上真实剩余天数」而不是 KV 里那份:KV 可能过期/失同步,
// 线上才决定读者会不会看到证书过期。