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

+17 -23
View File
@@ -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;
}
+5 -15
View File
@@ -12,7 +12,6 @@ import * as A from './routes/admin';
import * as H from './routes/human';
import * as E from './routes/editor';
import * as S from './routes/ssl';
import { renewAll } from './lib/certissue';
const router = new Router();
@@ -233,25 +232,16 @@ export default {
// 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
// 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存)
// 10 4 * * * = 证书续期检查(探针判定,≤30 天才签)
//
// ★ 2026-10-06:`10 4 * * *` 的**证书续期检查已移除** —— 签发+部署整条链路
// 搬到了国内机的 Docker 容器 `cn-certkeeper`(见 deploy/cn-certkeeper/)。
// 两边同时跑会重复签发(白耗 CA 配额)并争抢同一批站点部署。
// Worker 侧只保留**只读**能力:证书状态 / 自检 / 后台展示。
async scheduled(event: ScheduledController, env: Env): Promise<void> {
if (event.cron === '0 * * * *') {
await RssApi.scheduled(event, env, undefined as unknown as ExecutionContext);
return;
}
if (event.cron === '10 4 * * *') {
// 证书续期:每天跑一次,但**只有探针查到剩余 ≤30 天才真正签发**。
// 不无条件重签的原因见 lib/certissue.ts 头注释(会白烧 CA 限速额度)。
try {
const outcomes = await renewAll(env, { by: '自动续期' });
for (const o of outcomes) {
console.log(`[certkeeper] ${o.domain}: ${o.ok ? '✓' : '✗'} ${o.reason}`);
}
} catch (e) {
console.error('[certkeeper] scheduled renew failed:', e instanceof Error ? e.message : e);
}
return;
}
try {
await gcRateLimits(env);
await gcCaptcha(env);
+227 -23
View File
@@ -92,6 +92,51 @@ function b64uJson(v: unknown): string {
return b64u(enc.encode(JSON.stringify(v)));
}
/**
* 取出 JWK 的**公开部分** —— 只保留该密钥类型定义的那几个成员。
*
* ★★ 为什么必须有这一步(2026-10-06 实测踩坑,代价是一次全线签发失败):
*
* 账户密钥是 `exportKey('jwk', privateKey)` 出来的,里面**同时**带着
* `d`(私钥标量)、`key_ops`、`ext`。早先我们把这个对象**原样**塞进
* `protected.jwk`(以及 EAB 的 payload),发出去长这样:
*
* {"alg":"ES256","nonce":"…","url":"…",
* "jwk":{"key_ops":["sign"],"ext":true,"kty":"EC","x":"…","y":"…","crv":"P-256","d":"…"}}
*
* 症状:LiteSSL 回 `403 {"detail":"newAccount JWS signature is invalid"}`。
* 而**本地验签是过的** —— 拿 x/y 造公钥、验 raw r||s,结果 true。
* 也就是说:问题不在签名算法,而在 JWK 的**内容语义**。
*
* 规范依据:
* · RFC 8555 §6.2 —— `jwk` 字段必须是**公钥**;
* · RFC 8555 §7.3.4 —— EAB 的内层 payload 同样是「账户公钥的 JWK 形式」;
* · RFC 7517 §6.2.1 —— EC 公钥只定义 crv/kty/x/y 四个成员。
* 多出来的成员会让严格实现(go-jose / 自研校验)拒绝、或把 thumbprint 算歪。
*
* ★ 顺带还是一个**安全修复**:私钥标量 `d` 绝不该发到 CA 那边去。
* (`jwkThumbprint()` 一直只用 crv/kty/x/y,所以它不受影响、无需统一。)
*/
function publicJwk(jwk: JsonWebKey): JsonWebKey {
switch (jwk.kty) {
case 'EC':
return { kty: 'EC', crv: jwk.crv, x: jwk.x, y: jwk.y };
case 'RSA':
return { kty: 'RSA', n: jwk.n, e: jwk.e };
case 'OKP':
return { kty: 'OKP', crv: jwk.crv, x: jwk.x };
default: {
// 兜底:调用方给了没见过的 kty,至少把**所有私钥/语义成分**摘干净
const out: JsonWebKey = { ...jwk };
for (const k of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'o', 'k', 'key_ops', 'ext', 'use', 'alg'] as const) {
// 走一次 unknown:`JsonWebKey` 没有索引签名,直接转 Record 会被 TS 判为可疑转换
delete (out as unknown as Record<string, unknown>)[k];
}
return out;
}
}
}
/** PEM 换行(64 列),末尾留换行 —— 多数软件(1Panel / nginx)都要求这样 */
function toPem(der: ArrayBuffer, label: string): string {
const b = new Uint8Array(der);
@@ -177,11 +222,17 @@ export class AcmeClient {
return n;
}
/** 有 kid → 用 kid;没有(注册阶段)→ 用 jwk */
/**
* 有 kid → 用 kid;没有(注册阶段)→ 用 jwk。
*
* ★ 用 `publicJwk()` 裁剪,**不能**直接把 `this.account.jwk` 放进去 ——
* 那里面带着私钥 `d` / `key_ops` / `ext`,会被 CA 判签名无效,
* 顺带还把私钥发出去了。详见 publicJwk() 的注释。
*/
private protectedHeader(nonce: string, url: string): Record<string, unknown> {
const base: Record<string, unknown> = { alg: 'ES256', nonce, url };
if (this.account.kid) base.kid = this.account.kid;
else base.jwk = this.account.jwk;
else base.jwk = publicJwk(this.account.jwk);
return base;
}
@@ -208,18 +259,28 @@ export class AcmeClient {
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
const key = await this.importSigningKey();
const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`;
// ★ payload === undefined 表示「**真的空** payload(零字节)」,见 postAsGet()。
// 千万别图省事传 `''` —— `b64uJson('')` 编出来是 `IiI`(JSON 字符串 `""`),
// 服务端 base64url 解码拿到两个字符 `"`,而不是空。
// LiteSSL 实测直接回 400 `Expected JWS payload message`;ZeroSSL 宽容放行 ——
// 所以这个 bug 在换 CA 之前一直藏着。
const p = payload === undefined ? '' : b64uJson(payload);
const signingInput = `${b64uJson(protectedHeader)}.${p}`;
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
enc.encode(signingInput),
);
// ★ 不包 DER —— WebCrypto 已经是 JWS 要的 r||s
return JSON.stringify({ protected: b64uJson(protectedHeader), payload: b64uJson(payload), signature: b64u(sig) });
return JSON.stringify({ protected: b64uJson(protectedHeader), payload: p, signature: b64u(sig) });
}
/** 发一个 POST;自动带 nonce、自动在 badNonce 时重试 */
private async post(url: string, payload: unknown, opts: { useJwk?: boolean; retries?: number } = {}): Promise<Response> {
private async post(
url: string,
payload: unknown,
opts: { useJwk?: boolean; retries?: number; accept?: string } = {},
): Promise<Response> {
const retries = opts.retries ?? 2;
const dir = await this.directory();
void dir;
@@ -233,7 +294,12 @@ export class AcmeClient {
const body = await this.signJws(this.protectedHeader(nonce, url), payload);
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/jose+json', Accept: 'application/json' },
headers: {
'Content-Type': 'application/jose+json',
// ★ 只有**下载证书**那一步要用 `application/pem-certificate-chain`
// (RFC 8555 §7.4.2),其余一律 JSON。传错会怎样见 postAsGet 的注释。
Accept: opts.accept || 'application/json',
},
body,
});
const n = r.headers.get('Replay-Nonce');
@@ -259,9 +325,38 @@ export class AcmeClient {
}
}
/** POST-as-GET(RFC 8555 §6.3):读资源也要用 POST 签名,不能直接 GET */
private async postAsGet(url: string): Promise<Response> {
return this.post(url, ''); // payload 为空字符串(不是 null —— null 是「要服务端删掉字段」)
/**
* POST-as-GET(RFC 8555 §6.3):读资源也要用 POST 签名,不能直接 GET。
*
* ★ payload 必须是**零长度的八位字节串**,不是 JSON 的 `null`(那是「让服务端
* 删掉字段」),也**不是** JSON 的空字符串 `""`。这里传 `undefined` 走
* signJws() 的空 payload 分支 —— 传 `''` 会编成 `IiI`,LiteSSL 会 400。
*
* ★ `accept` 只在**下载证书**时需要传 `application/pem-certificate-chain`,
* 见下面 downloadCert() 的注释(这一条最坑,2026-10-06 排查了很久)。
*/
private async postAsGet(url: string, accept?: string): Promise<Response> {
return this.post(url, undefined, { accept });
}
/**
* 下载证书链。
*
* ★★ 必须显式声明 `Accept: application/pem-certificate-chain`(RFC 8555 §7.4.2)。
*
* 2026-10-06 实测:这一步是整个签发链路的**最后一个坑**,而且最难定位 ——
* newOrder / 授权 / finalize / 轮询订单**全部 200**、订单状态确实走到了
* `valid`(证书已经在 CA 那边签出来了),然后下载那一步回
* 500 {"type":"…:serverInternal","detail":"The server experienced an internal error"}
* —— 看上去像「签发被内部错误挡住了」,实际是**内容协商**:
* 我们一直发 `Accept: application/json`(post() 的默认值),
* 而 LiteSSL 只实现了 PEM 这一种媒体类型,协商不上就内部 500。
* (零依赖实现 ACME 极易漏掉这一条:其它客户端都把 Accept 交给 http 库的
* content-type 协商处理,我们手写 fetch 就必须自己写对。)
*/
private async downloadCert(url: string): Promise<string> {
const r = await this.postAsGet(url, 'application/pem-certificate-chain');
return r.text();
}
// ---------------------------------------------------------- 账户
@@ -279,10 +374,17 @@ export class AcmeClient {
};
if (eab) {
// ★ 内层 JWS:protected 只放 alg/kid/url,payload 是账户 JWK 本身
// ★ 内层 JWS:protected 只放 alg/kid/url,payload 是账户**公钥**的 JWK
// (RFC 8555 §7.3.4 —— 同样是公钥,别把带 d 的私钥对象塞进去)
const innerProtected = b64uJson({ alg: 'HS256', kid: eab.kid, url: d.newAccount });
const innerPayload = b64uJson(this.account.jwk);
const rawKey = Uint8Array.from(atob(eab.hmacKeyB64.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
const innerPayload = b64uJson(publicJwk(this.account.jwk));
// ★ EAB 的 hmac key 各家编码不一:ZeroSSL 给标准 base64,LiteSSL 给 base64url。
// 先统一成标准 base64,**再补上 padding** —— 有些运行时(严格模式的 atob)
// 对缺 `=` 的串会直接抛 InvalidCharacterError,而错在这是「账户注册」这一步,
// 报出来会很难往「少了两个等号」上想。
const b64 = eab.hmacKeyB64.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
const rawKey = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
const hmacKey = await crypto.subtle.importKey('raw', rawKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const innerSig = await crypto.subtle.sign('HMAC', hmacKey, enc.encode(`${innerProtected}.${innerPayload}`));
payload.externalAccountBinding = {
@@ -331,8 +433,17 @@ export class AcmeClient {
const orderUrl = orderRes.headers.get('Location') || '';
this.log(`订单已创建(${domains.join(', ')}),状态 ${order.status}`);
// ② 逐个授权:写 DNS → 通知就绪 → 轮询
// ② 逐个授权:读状态 → 算 TXT → **把 TXT 全写完**(先不通知 CA)
//
// ★ 为什么「先写完再统一等」(2026-10-06 实测踩坑):
// wildcard + apex(`*.t-t.live` 与 `t-t.live`)在 ACME 里是**两张独立授权**,
// 但 DNS-01 的 TXT 名字是**同一个** `_acme-challenge.t-t.live`,
// 两张授权各自的期望值**不同** —— 必须**同时存在**才能一次验过。
// 老写法是「写一条 → 等 30s → 通知 → 轮询 → 再写下一条」,
// 等于给每条授权各等 30s(60s 白等),而且第二张常常在等待期间
// 被服务端顺带置成 valid → 见下面 ④ 的说明。
const written: { name: string; value: string }[] = [];
const pending: { authzUrl: string; chalUrl: string; label: string }[] = [];
try {
for (const authzUrl of order.authorizations) {
const authz = (await (await this.postAsGet(authzUrl)).json()) as {
@@ -356,32 +467,77 @@ export class AcmeClient {
this.log(`写 TXT:${recName} = ${txtValue.slice(0, 16)}…`);
await setTxt(recName, txtValue);
written.push({ name: recName, value: txtValue });
pending.push({ authzUrl, chalUrl: chal.url, label: authz.identifier.value });
}
// ③ 等 DNS 传播 —— 不给自己留这个时间,验证会一直 pending 到超时
// ③ 等 DNS 传播 —— **所有 TXT 写完后统一等一次**
// 不给自己留这个时间,验证会一直 pending 到超时。
if (pending.length) {
const wait = opts.waitSeconds ?? 30;
if (wait > 0) {
this.log(`等 ${wait}s 让 DNS 生效…`);
await sleep(wait * 1000);
}
// ④ 通知 CA 开始验证
await this.post(chal.url, {});
await this.pollAuthz(authzUrl, opts.timeoutMs ?? 180_000);
}
// ④ 逐个「通知 CA 开始验证」+ 轮询
//
// ★★ **绝不能盲发 challenge**(2026-10-06 实测踩坑,整张证书签不出来):
// 对一张**非 pending** 的授权发 challenge,LiteSSL 回
// 400 urn:ietf:params:acme:error:malformed "authorization must be pending"
// wildcard + apex 共用一个 TXT 名字,第一张验过之后服务端常把第二张
// 一起置为 valid —— 此时它已经不是 pending 了,不必(也不能)再通知。
// 所以发之前**重新读一次状态**:还是 pending 才发;否则交给 pollAuthz 定论
// (valid → 通过,invalid → 由 pollAuthz 抛出带原因的错,信息不丢)。
//
// ★★ 但「先读再发」**挡不住竞态**(2026-10-06 下午 200181.xyz 实测又踩):
// 读到的确是 pending,可等到 POST 打到服务端时状态已经翻过去了 ——
// 两个请求之间只差毫秒,这个窗口关不掉。
// 所以还必须在 POST 这一侧做**幂等容错**:把「authorization must be pending」
// 当成「已经不需要通知了」而不是错误,交给 pollAuthz 定论。
// 判据要卡得很死:只认这一句,且后续仍走 pollAuthz ——
// 真出问题(比如 DNS 没生效导致 invalid)依旧会由 pollAuthz 抛出带原因的错误,
// 不会把失败吞掉。
for (const p of pending) {
const cur = (await (await this.postAsGet(p.authzUrl)).json()) as { status: string };
if (cur.status === 'pending') {
try {
await this.post(p.chalUrl, {});
} catch (e) {
const m = e instanceof Error ? e.message : String(e);
if (!/authorization must be pending/i.test(m)) throw e;
this.log(`${p.label} 通知验证时状态已翻过 pending(竞态),改由轮询定论`);
}
} else {
this.log(`${p.label} 状态已变为 ${cur.status}(等待期间被服务端置位,跳过通知)`);
}
await this.pollAuthz(p.authzUrl, opts.timeoutMs ?? 180_000);
}
// ④.5 ★ 等订单进入 `ready` 再 finalize(RFC 8555 §7.1.1 / §7.4)
//
// ★★ 为什么必须有(2026-10-06 实测踩坑):
// 当授权是**被服务端复用**的(客户端读到的已经是 valid,上面直接 `跳过`),
// 订单在创建那一刻状态还是 `pending` —— 服务端还没来得及把它算成 `ready`。
// 此时直接 POST finalize,LiteSSL 回的是
// 500 {"type":"…:serverInternal","detail":"The server experienced an internal error"}
// 这句 500 完全指不到「订单没 ready」,害得人先去怀疑 CSR 的 DER 编码
// (离线用 openssl 验过,CSR 本身没问题)。
// 规范只允许在 `ready` 时 finalize,所以这里先轮询到位。
if (orderUrl) await this.waitOrderReady(orderUrl, opts.timeoutMs ?? 180_000);
// ⑤ finalize:CSR
const certKeyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
])) as CryptoKeyPair;
const csrDer = await makeCsr(certKeyPair, domains);
const csrDer = await makeCsrImpl(certKeyPair, domains);
this.log('提交 CSR…');
await this.post(order.finalize, { csr: b64u(csrDer) });
// ⑥ 轮询订单直到 valid,然后下载证书链
const certUrl = await this.pollOrder(orderUrl, opts.timeoutMs ?? 180_000);
const certRes = await this.postAsGet(certUrl);
const certPem = await certRes.text();
const certPem = await this.downloadCert(certUrl);
// ★ exportKey('pkcs8') 的 TS 重载返回 ArrayBuffer | JsonWebKey(因为 format 是联合),
// 这里 format 已确定是 pkcs8,用 exportDer 包装断言回 ArrayBuffer。
@@ -402,8 +558,42 @@ export class AcmeClient {
}
}
private async pollAuthz(url: string, timeoutMs: number): Promise<void> {
/**
* 等订单进入 `ready` —— 只有 `ready` 才允许 finalize(RFC 8555 §7.1.1)。
*
* 正常路径下(授权是这次新验的)订单通常**瞬间**就是 ready,这个函数会
* 一次就返回;真正需要它的是「授权被服务端复用、订单仍是 pending」那种情况。
*
* `valid` 视作异常:说明订单在我们还没提交 CSR 的情况下就完成了 ——
* 那时我们手里没有与之匹配的私钥,拿着一张文不对题的证书比直接报错危险得多。
*/
private async waitOrderReady(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let last = '';
for (;;) {
const o = (await (await this.postAsGet(url)).json()) as {
status: string;
error?: { detail?: string };
};
if (o.status === 'ready') {
if (last !== 'ready') this.log(`订单状态 ${last || '?'} → ready,可以 finalize`);
return;
}
if (o.status === 'valid') {
throw new Error('订单在 finalize 之前就变成 valid —— 客户端没有与之匹配的私钥,拒绝继续');
}
if (o.status === 'invalid') {
throw new Error(`订单在 finalize 前变为 invalid:${o.error?.detail || '未知原因'}`);
}
if (Date.now() > deadline) {
throw new Error(`订单迟迟没进入 ready(${timeoutMs / 1000}s,当前状态 ${o.status})`);
}
last = o.status;
await sleep(2000);
}
}
private async pollAuthz(url: string, timeoutMs: number): Promise<void> { const deadline = Date.now() + timeoutMs;
for (;;) {
const a = (await (await this.postAsGet(url)).json()) as {
status: string;
@@ -490,7 +680,7 @@ export async function newAccountKey(): Promise<JsonWebKey> {
* signature BIT STRING
* }
*/
async function makeCsr(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
async function makeCsrImpl(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
const cn = domains[0];
const pub = await exportJwk(keyPair.publicKey);
const x = b64uToBytes(pub.x!);
@@ -560,6 +750,20 @@ async function makeCsr(keyPair: CryptoKeyPair, domains: string[]): Promise<Array
return csr.buffer as ArrayBuffer;
}
/**
* 供**离线自测**用的 CSR 导出(见 `tools/selftest-csr.mjs`)。
*
* ★ 为什么值得单独开一个口子:CSR 是**手写 DER**(不引 asn1.js / pkijs,
* 理由见上面 makeCsr 的注释)。少一个长度字节、多套一层 SEQUENCE,
* CA 那边往往只回一句极含糊的错 —— LiteSSL 实测直接 500
* `The server experienced an internal error`,完全指不到点上。
* 离线跑一遍 `openssl req -inform DER -verify`,比拿真订单去试快得多,
* 也不会白烧 CA 的订单配额。
*/
export async function makeCsrForTest(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
return makeCsrImpl(keyPair, domains);
}
// ---- 极简 DER 编码器 ----
function concat(...arrs: Uint8Array[]): Uint8Array {
+16 -5
View File
@@ -118,8 +118,11 @@ export async function issueDomain(env: Env, d: DomainConfig, opts: IssueOptions
// ---- ① 要不要签?先看线上真实剩余天数 ----
if (!opts.force) {
const host = d.san.find((s) => !s.startsWith('*.')) || name;
const live = await probeTls(host, 8000, env.EDITOR_API_BASE, env.EDITOR_TOKEN);
// ★ 探测用 `probe_sni`(默认 san 里第一个非泛域名)。
// 配了 `probe_connect` 就连源站 IP —— 域名挂在 CDN/边缘加速后面时,
// 公网握手拿到的是边缘证书,会永远判「还很新」而漏掉源站过期。
const host = d.probe_sni || d.san.find((s) => !s.startsWith('*.')) || name;
const live = await probeTls(host, 8000, env.EDITOR_API_BASE, env.EDITOR_TOKEN, d.probe_connect);
const liveLeft = daysLeft(live.notAfter);
if (live.ok && liveLeft !== null && liveLeft > RENEW_BEFORE_DAYS) {
// 线上证书还好好的 —— 顺手把探针拿到的真实信息补进库里(KV 里可能是旧记录)
@@ -245,7 +248,9 @@ export async function issueDomain(env: Env, d: DomainConfig, opts: IssueOptions
try {
const dp = makeDeployer(cred);
const res = await dp.deploy(
{ domain: name, cert: rec.cert, key: rec.key },
// ★ notAfter 必须传:多吉云靠它判断「已有的同组域名证书够不够新」,
// 不传就会保守地每次都传新的(不至于错,但会白堆证书)。
{ domain: name, cert: rec.cert, key: rec.key, notAfter: rec.expireAt },
{
dogecloudDomains: d.dogecloud_domains,
onePanelSites: d.one_panel_sites,
@@ -330,11 +335,17 @@ async function findEabAccess(env: Env, d: DomainConfig): Promise<Record<string,
}
function issuerFromDirectory(url: string): string {
if (url.includes('trustasia')) return 'LiteSSL (TrustAsia)';
// ★ 顺序有讲究(2026-10-06 实测踩坑):
// LiteSSL 的**正确**目录是 `https://acme.litessl.com/acme/v2/directory`,
// 而 `'litessl.com'.includes('ssl.com')` 是 **true**("lite|ssl.com"),
// 所以 `ssl.com` 那条判断会把 LiteSSL 误标成「SSL.com」——
// 日志里就出现了「ACME 账户就绪(SSL.com)」这种一看就很可疑的输出。
// LiteSSL / TrustAsia 必须排在前面,且 ssl.com 要匹配到 host 边界。
if (url.includes('litessl') || url.includes('trustasia')) return 'LiteSSL (TrustAsia)';
if (url.includes('letsencrypt')) return "Let's Encrypt";
if (url.includes('zerossl')) return 'ZeroSSL';
if (url.includes('google')) return 'Google Trust Services';
if (url.includes('ssl.com')) return 'SSL.com';
if (/(^|\/\/|\.)acme\.ssl\.com|(^|\/\/)ssl\.com\//.test(url)) return 'SSL.com';
if (url.includes('buypass')) return 'Buypass';
return url.replace(/^https?:\/\//, '').split('/')[0];
}
+103 -21
View File
@@ -59,29 +59,38 @@ export async function probeTls(
timeoutMs = 8000,
probeBase?: string,
probeToken?: string,
/**
* 连到**哪个地址**(默认就是 `host` 自己走公网解析)。
*
* ★ 用途:域名挂在 CDN / 边缘加速后面时,公网握手拿到的是边缘证书;
* 传源站 IP 进来才能量到「我们真正要部署的那张源站证书」。
* SNI 仍然是 `host`,所以目标 IP 上必须有能匹配这个 SNI 的 vhost。
*/
connectTo?: string,
): Promise<TlsInfo> {
const h = String(host || '').trim().toLowerCase();
const c = String(connectTo || '').trim().toLowerCase();
if (!h || !/^[a-z0-9.*-]+$/.test(h)) return { ok: false, error: '域名不合法' };
if (h.includes('*')) return { ok: false, error: '通配符域名不能直接握手(请指定具体主机名)' };
// ── ① 国内机探针(真 Node,能拿到证书正文)──
if (probeBase) {
const viaCn = await probeViaRemote(probeBase, h, timeoutMs, probeToken);
const viaCn = await probeViaRemote(probeBase, h, timeoutMs, probeToken, c || undefined);
if (viaCn && viaCn.ok && viaCn.notAfter) return viaCn;
// 拿不到正文但确认可达 → 记下来,等本地兜底也没结果时再用它
if (viaCn && viaCn.ok) {
const local = await probeLocal(h, timeoutMs);
const local = await probeLocal(h, timeoutMs, c || undefined);
return local.notAfter ? local : { ...viaCn, error: local.error || viaCn.error };
}
// 国内探针明确报错(DNS/连接失败)通常就是真相,本地再试一次也只是复核
if (viaCn && !viaCn.ok && !viaCn.error?.startsWith('国内探针不可用')) {
const local = await probeLocal(h, timeoutMs);
const local = await probeLocal(h, timeoutMs, c || undefined);
return local.ok || local.notAfter ? local : viaCn;
}
}
// ── ② 本地兜底 ──
return probeLocal(h, timeoutMs);
return probeLocal(h, timeoutMs, c || undefined);
}
/** 通过国内机 editor-api 的 /ssl-probe 拿证书(那边是真 Node,方法可用) */
@@ -90,9 +99,12 @@ async function probeViaRemote(
host: string,
timeoutMs: number,
token?: string,
connectTo?: string,
): Promise<TlsInfo | null> {
try {
const url = base.replace(/\/+$/, '') + '/ssl-probe?host=' + encodeURIComponent(host);
let url = base.replace(/\/+$/, '') + '/ssl-probe?host=' + encodeURIComponent(host);
// ★ 源站直连:让国内机的探针连到指定 IP,SNI 仍是 host
if (connectTo) url += '&connect=' + encodeURIComponent(connectTo);
// ★ 探针要走本机 editor-api。该机除 /health 外一律要 X-Editor-Token,
// 所以这里必须带上共享令牌 —— 它只存在于 Worker 环境变量里,浏览器拿不到。
const headers: Record<string, string> = { Accept: 'application/json' };
@@ -120,10 +132,14 @@ async function probeViaRemote(
}
/**
* Workers 本地握手。
* ★ 只能确认「可达 / 握手是否成功」,**拿不到证书正文**(原因见 probeTls 注释)。
* 本地握手。
* ★ Workers 运行时只能确认「可达 / 握手是否成功」,**拿不到证书正文**;
* 真 Node(国内机容器)下 getPeerCertificate 是完整实现的,能拿到全部字段。
*
* `connectTo`:连到指定地址(源站 IP),SNI 仍用 `h`。
*/
async function probeLocal(h: string, timeoutMs: number): Promise<TlsInfo> {
async function probeLocal(h: string, timeoutMs: number, connectTo?: string): Promise<TlsInfo> {
const dialHost = connectTo || h;
return new Promise<TlsInfo>((resolve) => {
let settled = false;
let sock: { destroy: () => void } | null = null;
@@ -148,7 +164,9 @@ async function probeLocal(h: string, timeoutMs: number): Promise<TlsInfo> {
const s = tls.connect(
{
host: h,
// ★ connectTo 存在时连源站 IP;SNI 始终用 h(域名),
// 这样源站 nginx 才能按 server_name 选到对的 vhost。
host: dialHost,
port: 443,
servername: h, // SNI:泛域名站点不带这个会拿到默认证书
// ★ 不要传 rejectUnauthorized / timeout —— Workers 的 node:tls
@@ -226,6 +244,51 @@ function parseAltNames(s: string): string[] {
.filter(Boolean);
}
/** 读 DER 的长度字段(短形式 / 长形式),返回数值与下一字节位置 */
function derLen(s: string, p: number): { value: number; next: number } {
const b = s.charCodeAt(p);
if (b < 0x80) return { value: b, next: p + 1 };
const n = b & 0x7f;
let v = 0;
for (let i = 0; i < n; i++) v = v * 256 + s.charCodeAt(p + 1 + i);
return { value: v, next: p + 1 + n };
}
/**
* 精确定位 SAN 扩展(OID 2.5.29.17)并读出其中的 `[2] dNSName` 列表。
* 返回空数组表示「没找到 / 结构不认识」,调用方再回退到启发式。
*/
function parseSanFromDer(der: string): string[] {
const oid = '\x06\x03\x55\x1d\x11'; // 2.5.29.17 subjectAltName
const at = der.indexOf(oid);
if (at < 0) return [];
let p = at + oid.length;
// 扩展值:OCTET STRING,里面是 SEQUENCE OF GeneralName
if (der.charCodeAt(p) !== 0x04) return [];
const l1 = derLen(der, p + 1);
p = l1.next;
const endOuter = p + l1.value;
if (der.charCodeAt(p) !== 0x30) return [];
const l2 = derLen(der, p + 1);
p = l2.next;
const end = Math.min(p + l2.value, endOuter);
const out: string[] = [];
while (p < end) {
const tag = der.charCodeAt(p);
p += 1;
const l = derLen(der, p);
p = l.next;
const v = der.slice(p, p + l.value);
p += l.value;
if (tag === 0x82 && v) {
// [2] dNSName
const d = v.toLowerCase();
if (d && !out.includes(d)) out.push(d);
}
}
return out;
}
/**
* 从 PEM 证书正文里读到期日 / SAN。
* ACME 签发完、或者用户手工粘贴一张证书时用它落库(不用再连一次网络)。
@@ -233,16 +296,33 @@ function parseAltNames(s: string): string[] {
* 实现上走 `crypto.subtle` 的 X.509 解析没有标准 API,
* 所以这里只做**最小解析**:从 base64 DER 里定位 UTCTime/GeneralizedTime。
* 够用且不引依赖 —— 真正的权威判断还是 probeTls 的实测。
*
* ★★ 2026-10-06 修了两个会让调用方静默拿到空值的毛病(起因是多吉云复用一直不生效):
*
* ① **多段完整链解析直接失败**。原实现把**所有** `-----BEGIN/END CERTIFICATE-----`
* 标记一删、把各段 base64 直接拼起来再 `atob`。单段证书没事,但链里第二段
* base64 的 `=` 填充夹在中间会让 `atob` 抛错,函数返回 `{}`。
* 而我们存进 KV 的正是**完整链**(叶 + 中间证书),于是:
* · `rec.expireAt` 静默退化成调用方的兜底值「签发时刻 + 90 天」,
* 与真实 notAfter 差 1~2 小时;
* · 多吉云「已有证书够不够新」的比对因此永远为假 → **每次续期都重传一张**。
* 改成**只解析第一段**(链里叶证书在最前),这才是语义上正确的那张。
* 实测:3 段链 → `{}`;只喂叶证书 → `notAfter 1799056799000`(= 2027-01-04T09:59:59Z,
* 与 `openssl x509 -enddate` 完全一致)。
*
* ② **SAN 靠正则捞**,会把 issuer、AIA/CRL 的 URL 一起捞进来
* (实测混进 `ica.litessl.com`、`crl.litessl.com`、`litessleccca2025-part10.crl`
* 这类垃圾)。改为精确定位 SAN 扩展 OID `2.5.29.17` 再逐个读 `[2] dNSName`;
* 万一定位失败才回退到旧启发式,不会比原来更差。
*/
export function parsePemInfo(pem: string): { notAfter?: number; altNames?: string[] } {
const b64 = String(pem || '')
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s+/g, '');
if (!b64) return {};
// ★ 只取第一段(叶证书)。多段拼接会让 atob 因中间的 `=` 填充而抛错。
const blocks = String(pem || '').match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
const leafPem = blocks[0];
if (!leafPem) return {};
let der: string;
try {
der = atob(b64);
der = atob(leafPem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''));
} catch {
return {};
}
@@ -260,12 +340,14 @@ export function parsePemInfo(pem: string): { notAfter?: number; altNames?: strin
Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])),
);
}
// SAN 的 DNS 名字在 DER 里是可见 ASCII(标签 0x82 后跟长度)
const alt: string[] = [];
const san = der.match(/[a-z0-9*.-]+\.[a-z]{2,}/gi) || [];
for (const s of san) {
const v = s.toLowerCase();
if (!alt.includes(v) && !v.includes('..')) alt.push(v);
// SAN:先按扩展精解;拿不到再回退到旧的「在 DER 里捞像域名的串」启发式
let alt = parseSanFromDer(der);
if (!alt.length) {
const san = der.match(/[a-z0-9*.-]+\.[a-z]{2,}/gi) || [];
for (const s of san) {
const v = s.toLowerCase();
if (!alt.includes(v) && !v.includes('..')) alt.push(v);
}
}
const sorted = times.filter((t) => t > Date.UTC(2000, 0, 1)).sort((a, b) => a - b);
return {
+31
View File
@@ -64,6 +64,25 @@ export interface DomainConfig {
deploy: string[];
dogecloud_domains?: string[];
one_panel_sites?: string[];
/**
* 探针**连到哪个地址**(默认拿 san 里的主机名做公网解析)。
*
* ★ 为什么需要它:域名挂在 CDN / 边缘加速(如腾讯云 EO)后面时,
* 公网握手拿到的是**边缘节点**的证书,而我们要续的是**源站**那张。
* 边缘证书通常由 CDN 自动维护、长期「很新」→ 拿它判续期会**永远判
* 「还剩 80 多天」→ 源站证书到期也不续**,站点悄悄挂掉。
* 填上源站 IP,探针就直接量到「我们即将部署的那张证书」。
*/
probe_connect?: string;
/**
* 探针发出去的 **SNI**(默认 = san 里第一个非泛域名)。
*
* ★ 为什么和 probe_connect 分开:源站上未必有与域名同名的站点。
* 比如 `usj.cc` 在 1Panel 里**没有**同名网站(它只是证书名),
* 直接拿 `usj.cc` 当 SNI 会落到默认 server、拿到别的证书。
* 此时把 SNI 指到真正引用该证书的站点(如 `artalk.usj.cc`)即可。
*/
probe_sni?: string;
/** 关掉这个域名的一切自动动作(保留配置,只是不跑) */
disabled?: boolean;
}
@@ -145,6 +164,15 @@ function migrateConfig(c: KeeperConfig): KeeperConfig {
return out;
}
/**
* 归一化单个域名配置。
*
* ★★ 这是**字段白名单**:没在这里列出来的字段会被**静默丢掉**。
* 加新字段时**必须**同步补到这里,否则表现为「配置里明明写了,跑起来就是不生效」——
* 2026-10-06 就踩过一次:`probe_connect` / `probe_sni` 写进了 CONFIG,
* 导出到国内机也对的(文件里 grep 得到),但 loadConfig 一过就没了,
* 探针照旧走公网、t-t.live 被判成「还剩 83 天」。
*/
function normalizeDomain(d: DomainConfig): DomainConfig {
return {
name: String(d?.name || '').trim().toLowerCase(),
@@ -153,6 +181,9 @@ function normalizeDomain(d: DomainConfig): DomainConfig {
deploy: Array.isArray(d?.deploy) ? d.deploy.map((s) => String(s).trim()).filter(Boolean) : [],
...(d?.dogecloud_domains ? { dogecloud_domains: d.dogecloud_domains.map(String) } : {}),
...(d?.one_panel_sites ? { one_panel_sites: d.one_panel_sites.map(String) } : {}),
// ★ 源站探针:域名挂在 CDN/边缘加速后面时必须留着,否则续期判定会失真
...(d?.probe_connect ? { probe_connect: String(d.probe_connect).trim() } : {}),
...(d?.probe_sni ? { probe_sni: String(d.probe_sni).trim().toLowerCase() } : {}),
...(d?.disabled ? { disabled: true } : {}),
};
}
+296 -43
View File
@@ -24,6 +24,19 @@ export interface DeployCert {
cert: string;
/** 私钥 PEM */
key: string;
/**
* 证书到期时间(epoch ms)。
*
* ★ 为什么部署器需要知道「我们这张有多新」(2026-10-06 加):
* 多吉云为了不给证书列表堆垃圾,上传前会先「找一张覆盖同组域名的已有证书复用」。
* 但多吉云的 list 接口**不返回 PEM 正文**,没法比对内容 —— 如果只看
* 「域名集合相同」,那么**第一次上传之后,后续续期永远会命中那张旧证书并复用**,
* 新证书一张也传不上去:CDN 一直用旧证书,直到旧证书过期。
* 于是改成用**到期时间**当新鲜度代理指标:只有「已有那张到期不早于我们这张」
* 才复用。拿不到这个值时就保守地**不复用**(多传一张的代价是列表多一条,
* 而复用错了的代价是线上证书静默过期)。
*/
notAfter?: number;
}
export interface DeployResult {
@@ -89,9 +102,12 @@ function bufToHex(buf: ArrayBuffer | Uint8Array): string {
* 用假 id 999999 试 `{cert_id,…}` 回「域名不存在」(参数被无视),
* 试 `{id,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。
*
* ★ 幂等策略:上传前先列 cert 列表,若已存在「同一组域名 + 内容相同」的证书
* 就直接复用它的 id,不再上传。多吉云上传限速约 300 次/日,
* 每天续期检查跑 3 个域名,不做复用虽然也够,但证书列表会越堆越长。
* ★ 幂等策略(2026-10-06 修正):上传前先列 cert 列表,只有「同一组域名 **且到期不早于
* 本次**」的证书才复用。✗ 早期只判域名集合 —— 那会让**第一次上传之后的每次续期
* 都复用那张旧证书**,新证书永远传不上去(CDN 一路用旧证书到过期,日志却写「复用」)。
* 多吉云的 list 不返回 PEM,比不了内容,所以用到期时间当新鲜度代理。
* 复用失败就上传新的,并在绑定完成后清掉被取代的、且已无人引用的旧证书。
* 多吉云上传限速约 300 次/日,每天 3 个域名即使天天传也远够。
*/
export class DogeCloudDeployer implements Deployer {
readonly kind = 'dogecloud';
@@ -162,7 +178,8 @@ export class DogeCloudDeployer implements Deployer {
const log = opts.log || (() => {});
const details: string[] = [];
// ① 先看有没有可复用的证书(同一组域名)—— 避免每天续期都堆一张新的
// ① 找一张「覆盖同组域名且不比本次旧」的证书复用;没有就传新的。
// (早期只看域名集合,导致第一次之后永远复用旧证书 —— 见 uploadOrReuse 注释)
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
details.push(`证书 #${certId}`);
@@ -178,36 +195,58 @@ export class DogeCloudDeployer implements Deployer {
await this.call('/cdn/cert/bind.json', { id: certId, domain });
details.push(`绑定 ${domain}`);
}
// ③ 绑定成功后再清理被取代的旧证书(此时它们已不被引用)。
// 放最后且整体 try 住:清理失败绝不推翻上面已经成功的绑定。
await this.cleanupSuperseded(cert, domains, certId, log);
return { target: 'dogecloud', details };
}
/**
* 上传证书;如果已经有「覆盖同一组域名」的证书,直接复用它的 id。
* 上传证书;如果已经有「覆盖同一组域名 **且不比我们这张旧**」的证书,复用它。
*
* ★ 复用判据只看**域名集合**,不比对证书内容:
* 多吉云的 list 接口不返回 PEM 正文,比对不了内容;而我们的用途是
* 「让这些域名用上新证书」,同一组域名本来就该共用同一张证书。
* ★★★ 判据**必须**带上到期时间(2026-10-06 修)。
* 多吉云的 list 接口不返回 PEM 正文,没法比对内容;早期实现只比「域名集合」,
* 结果是:**第一次上传之后就再也不会传新的了** —— 每次续期都命中那张旧证书
* 然后「复用」,CDN 侧一路用旧证书到过期,而任务日志写着「复用,完成」。
* 现在多比一条:已有证书的 `expire`(秒)要 **>=** 我们这张的 `notAfter`,
* 才认为它「至少一样新」而复用。实测证书对象里确实有 `expire` /
* `expireText` / `issue` / `info.SAN` 这些字段。
* 拿不到 `cert.notAfter` 时保守处理:**不复用,直接上传**。
*
* ★ 上传新证书后顺手清掉**被取代的**同域名集旧证书(`cleanupSuperseded`):
* 否则一年 4 次续期会往 CDN 证书列表里堆 4 条。清理有严格前置条件
* (同域名集 + 更旧 + 当前没有任何加速域名在引用它),见那个方法。
*/
private async uploadOrReuse(cert: DeployCert, wantDomains: string[], log: (m: string) => void): Promise<number> {
const need = new Set(
(wantDomains.length ? wantDomains : [cert.domain]).map((s) => s.trim().toLowerCase()).filter(Boolean),
);
let existing: { id: number; name?: string; expire?: number; domains?: { name: string }[] }[] = [];
try {
const list = await this.call<{ certs?: { id: number; domains?: { name: string }[] }[] }>(
'/cdn/cert/list.json',
{},
);
const hit = (list?.certs || []).find((c) => {
const list = await this.call<{ certs?: typeof existing }>('/cdn/cert/list.json', {});
existing = list?.certs || [];
const hit = existing.find((c) => {
const have = new Set((c.domains || []).map((d) => String(d.name).toLowerCase()));
if (have.size !== need.size) return false;
for (const d of need) if (!have.has(d)) return false;
return true;
// ★ 域名集相同还不够:还得确认它不比我们这张旧
if (!cert.notAfter) return false; // 不知道自己的到期时间 → 不敢复用
const theirs = Number(c?.expire || 0) * 1000;
// 容差 1 天:`cert.notAfter` 来自 KV 里的 `expireAt`,而**历史记录**里存的是
// 旧版 parsePemInfo 在完整链上解析失败后退化的「签发时刻 + 90 天」(差 1~2 小时)。
// 1 天足以盖住这种偏差;而两次续期之间差着 30 天以上,绝不会误判成可复用。
return theirs > 0 && theirs >= cert.notAfter - 86_400_000;
});
if (hit?.id) {
log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id},复用`);
log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id}(到期不早于本次),复用`);
return hit.id;
}
if (existing.length) {
log(`多吉云:列表里 ${existing.length} 张证书都不够新(或域名集不匹配),上传新的`);
}
} catch (e) {
// 列举失败不阻断部署 —— 大不了多传一张,比整个部署失败好
log(`多吉云:列举已有证书失败(继续上传新的):${e instanceof Error ? e.message : e}`);
@@ -226,6 +265,77 @@ export class DogeCloudDeployer implements Deployer {
}
return Number(id);
}
/**
* 清掉被本次上传取代的旧证书(同域名集、更旧、且**当前没有任何加速域名引用**)。
*
* 为什么加:`uploadOrReuse` 现在每次续期都会传一张新的,不清理的话
* 多吉云证书列表会一年涨 4 条/域名。
*
* ★ 但**刻意保留最新的一代旧证书**(只删更早的)。
* 多吉云的 cert list 不给 PEM(`downloadable: 0`),删掉就真没了 ——
* 万一新证书在 CDN 侧出问题(比如最终端不支持 ECDSA),
* 手里那张刚被换下来的证书就是**唯一能一键绑回去的回退点**。
* 所以留 1 条:列表最多 2 条/域名集,既不失控也留了退路。
*
* 为什么敢删:四个条件同时满足才删 ——
* ① 域名集合与本次**完全相同**(不会误删别的域名的证书)
* ② `expire` 严格早于我们这张(它确实是旧的那张)
* ③ `/cdn/domain/list.json` 里**没有任何** `cert_id` 指向它
* (还在被用的证书绝不删)
* ④ 它不是「最新的一代旧证书」(见上)
* 任何一步出岔子都只是「少清理一条」,绝不影响线上。
*/
private async cleanupSuperseded(cert: DeployCert, wantDomains: string[], keepId: number, log: (m: string) => void): Promise<void> {
if (!cert.notAfter) return;
const need = new Set(
(wantDomains.length ? wantDomains : [cert.domain]).map((s) => s.trim().toLowerCase()).filter(Boolean),
);
try {
const list = await this.call<{ certs?: { id: number; expire?: number; domains?: { name: string }[] }[] }>(
'/cdn/cert/list.json',
{},
);
const dl = await this.call<{ domains?: { cert_id?: number }[] }>('/cdn/domain/list.json', {});
const inUse = new Set((dl?.domains || []).map((d) => Number(d.cert_id)).filter(Boolean));
const superseded: { id: number; expire: number }[] = [];
for (const c of list?.certs || []) {
if (c.id === keepId) continue;
const have = new Set((c.domains || []).map((d) => String(d.name).toLowerCase()));
if (have.size !== need.size) continue;
let same = true;
for (const d of need)
if (!have.has(d)) {
same = false;
break;
}
if (!same) continue;
const exp = Number(c.expire || 0) * 1000;
if (!(exp < cert.notAfter)) continue;
if (inUse.has(c.id)) {
log(`多吉云:旧证书 #${c.id} 仍被加速域名引用,保留不动`);
continue;
}
superseded.push({ id: c.id, expire: exp });
}
// 新的在前;保留第一条(= 回退点),只清理更早的
superseded.sort((a, b) => b.expire - a.expire);
const keepRollback = superseded[0];
if (keepRollback) log(`多吉云:保留上一代证书 #${keepRollback.id} 作为回退点`);
for (const c of superseded.slice(1)) {
try {
await this.call('/cdn/cert/delete.json', { id: c.id });
log(`多吉云:已清理更早的旧证书 #${c.id}`);
} catch (e) {
log(`多吉云:清理旧证书 #${c.id} 失败(不影响本次部署):${e instanceof Error ? e.message : e}`);
}
}
} catch (e) {
log(`多吉云:清理旧证书失败(不影响本次部署):${e instanceof Error ? e.message : e}`);
}
}
}
// ==================================================================== 1Panel
@@ -254,10 +364,16 @@ export class DogeCloudDeployer implements Deployer {
* `GET /websites/:id/https`。
*
* ★ 部署流程(**必须**先读后写):
* ① `POST /websites/ssl/search` 找同名 SSL;有就 `POST /websites/ssl/update` 覆盖,
* 没有就 `POST /websites/ssl/upload` 新建 → 得到 SSL id
* ② `GET /websites/:id/https` 读现状,若 `enable && ssl.id === 目标` → **跳过**(幂等)
* ③ `POST /websites/:id/https` 写入(`type:'existed'` 引用已有 SSL)
* ① `POST /websites/ssl/search` 找本域名对应的证书记录
* ② `POST /websites/ssl/upload` 写内容 —— **带 `sslID` 就是原地更新那条,
* 不带才是新建**。这个接口一次就把内容、`domains`、到期时间和各站点的
* `ssl/*.pem` 全都刷新好。
* ✗ 别用 `/websites/ssl/update`:它是「改 ACME 申请设置」的接口,
* `certificate`/`privateKey` 会被静默丢弃(续期会变成空转)。
* ③ `GET /websites/:id/https` 读现状,若 `enable && SSL.id === 目标`
* **且这次没有换过证书内容** → 跳过(幂等)
* ④ `POST /websites/:id/https` 写入(`type:'existed'` 引用已有 SSL)
* ★ 引用字段必须叫 **`websiteSSLId`**(不是 `sslId`,写错会回 500 record not found)
*
* ★ 网站匹配:域名优先(人配的是域名,id 会变),拿不到再当 id 用。
*/
@@ -398,45 +514,137 @@ export class OnePanelDeployer implements Deployer {
return page?.items || [];
}
/** 上传证书;同名同内容的已有 SSL 直接复用,避免面板里堆一堆 */
private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise<number> {
// ① 查同名(v2 的 ssl/search 同样需要 orderBy/order)
/** 拉证书列表(v2 的 ssl/search 同样必须带 orderBy/order,漏了 400) */
private async listSsl(): Promise<
{
id: number;
primaryDomain?: string;
domains?: string;
provider?: string;
description?: string;
expireDate?: string;
}[]
> {
const page = await this.call<PageResult<{ id: number; primaryDomain?: string }>>('/websites/ssl/search', 'POST', {
page: 1,
pageSize: 100,
orderBy: 'created_at',
order: 'descending',
});
const existing = (page?.items || []).find((s) => s.primaryDomain === cert.domain);
return page?.items || [];
}
if (existing?.id) {
// ② 同名存在 → 用 update 覆盖内容(保持 id 不变,网站那边的引用就不会断)
log(`1Panel:更新已有 SSL #${existing.id}(${cert.domain})`);
await this.call('/websites/ssl/update', 'POST', {
id: existing.id,
type: 'paste',
certificate: cert.cert,
privateKey: cert.key,
});
return existing.id;
}
/**
* 在证书库里找到「本域名对应的那条记录」。
*
* 为什么不能只按 `primaryDomain === cert.domain`:
* 1Panel 在 `Upload` 里会把 `primaryDomain` **重算成证书的第一个 SAN**
* (`websiteSSL.PrimaryDomain = cert.DNSNames[0]`)。
* 也就是说记录的 primaryDomain 是**CA 给的 SAN 顺序**决定的,不是我们配的。
* 一旦某次签发的 SAN 顺序被调换(先给通配),primaryDomain 就会变成
* `*.usj.cc`,此后按裸域名匹配就再也找不到 → **每次都新建一条重复记录**
* (这正是库里堆出 `#12` 那种空壳的成因)。
* 所以补一条兜底:记录自己的 `domains` 里写着目标域名,也算命中。
* 两级匹配 + 多命中时优先到期更晚的一条。
*/
private matchSslRecord<T extends { id: number; primaryDomain?: string; domains?: string; expireDate?: string }>(
list: T[],
domain: string,
): T | undefined {
const d = domain.toLowerCase();
const byPrimary = list.filter((s) => (s.primaryDomain || '').toLowerCase() === d);
const candidates = byPrimary.length
? byPrimary
: list.filter((s) =>
(s.domains || '')
.split(',')
.map((x) => x.trim().toLowerCase())
.includes(d),
);
if (!candidates.length) return undefined;
// 多命中时取到期最晚的一条(最可能是「当前在用的」那条)
return candidates.slice().sort((a, b) => String(b.expireDate || '').localeCompare(String(a.expireDate || '')))[0];
}
log('1Panel:上传新证书…');
const up = await this.call<{ id?: number } | number>('/websites/ssl/upload', 'POST', {
/**
* 把证书内容写进 1Panel 证书库;同名已有记录就**原地更新**,避免堆重复。
*
* ★★★ 唯一正确的接口是 **`POST /websites/ssl/upload`**,用 `sslID` 区分新建/更新
* (2026-10-06 读 v2.1.13 源码 + 实测确认,此前整段逻辑都是错的):
*
* service/website_ssl.go `Upload(req)`:
* if req.SSLID > 0 { websiteSSL = websiteSSLRepo.GetFirst(WithByID(req.SSLID)) }
* websiteSSL.PrivateKey = req.PrivateKey; websiteSSL.Pem = req.Certificate
* …重新解析证书…(重算 ExpireDate / Type / PrimaryDomain / Domains)
* if websiteSSL.ID > 0 { UpdateSSLConfig(*websiteSSL); return Save(websiteSSL) } ← 原地更新
* return Create(...) ← 新建
*
* 实测(#13,5 个站点):带 sslID 调一次即可
* · 记录数 5 → 5(不新增) · domains 由空**自动重算**回 `*.t-t.live`
* · **5 个站点的 ssl/*.pem 全部刷新** —— `UpdateSSLConfig` 负责物化
*
* ✗ 千万**不要**再用 `POST /websites/ssl/update` 来换内容 —— 它名字像,
* 实际是「改 ACME 申请设置」的接口。`WebsiteSSLUpdate` 结构体里
* **根本没有 `certificate` / `privateKey` 字段**,传了会被 Go 静默丢弃:
* · 证书内容一个字节都不会变(续期 = 完全空转)
* · 而且它的 `domains` 来自 `otherDomains`,我们没传 → **把 domains 清空**
* · 还会顺手把 `auto_renew` 置 false、`dns_account_id` 置 0
* 最坏的情况是「续期日志一切正常、线上证书永远不变」,只有旧证书到期才暴露。
*
* 返回值里的 `replaced`:true 表示「这条记录内容刚被换过、id 没变」。
* 虽然 `upload` 本身已经会刷新站点 `ssl/` 文件,但调用方**再强制绑一次**
* 可以顺带让 nginx 重新加载、并确认站点配置确实指向这张证书 ——
* 证书链路的静默失败代价是站点直接不可访问,这里的冗余是刻意留的。
*/
private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise<{ id: number; replaced: boolean }> {
const list = await this.listSsl();
const existing = this.matchSslRecord(list, cert.domain);
// 带 sslID = 原地更新;不带 = 新建(同一个接口两种语义)
const base: Record<string, unknown> = {
type: 'paste',
certificate: cert.cert,
privateKey: cert.key,
});
const id = typeof up === 'number' ? up : up?.id;
if (!id) throw new Error('1Panel 上传成功但没拿到 SSL id');
return id;
};
if (existing?.id) {
log(`1Panel:更新已有 SSL #${existing.id}(${cert.domain})`);
await this.call('/websites/ssl/upload', 'POST', {
...base,
sslID: existing.id,
// ★ `Upload` 在「更新」分支里会无条件 `websiteSSL.Description = req.Description`,
// 所以不把原值带回来就会把记录的说明清掉。带回来。
description: existing.description ?? '',
});
return { id: existing.id, replaced: true };
}
log('1Panel:上传新证书…');
const before = new Set(list.map((s) => s.id));
await this.call('/websites/ssl/upload', 'POST', { ...base, description: '' });
// ★★ `/websites/ssl/upload` **不回 id**(2026-10-06 实测):
// {"code":200,"message":"success","data":null}
// 原来的实现直接读 `up?.id`,于是必然抛
// 「1Panel 上传成功但没拿到 SSL id」—— 证书其实**已经建好了**,
// 我们却拿不到它,白建一条记录还部署不下去(多跑几次就堆一堆重复证书)。
// 正确姿势:上传后**再查一次库**,把新出现的那条捞回来。
// 挑法用 id 集合差集(比按时间猜稳),兜底再按域名匹配一次。
const after = await this.listSsl();
const fresh = this.matchSslRecord(
after.filter((s) => !before.has(s.id)),
cert.domain,
);
const id = fresh?.id ?? this.matchSslRecord(after, cert.domain)?.id;
if (!id) throw new Error('1Panel 上传成功但库里查不到新证书(回查也没找到同名记录)');
return { id, replaced: false };
}
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
const log = opts.log || (() => {});
const details: string[] = [];
const sslId = await this.uploadSsl(cert, log);
const { id: sslId, replaced } = await this.uploadSsl(cert, log);
details.push(`证书 SSL #${sslId}`);
const sites = (opts.onePanelSites || []).map((s) => s.trim()).filter(Boolean);
@@ -474,17 +682,62 @@ export class OnePanelDeployer implements Deployer {
hsts: resp?.hsts,
};
if (cur.enable && cur.sslId === sslId) {
// ★★★ 幂等判定的**两个条件缺一不可**:
// `enable && SSL.id === 目标 id` **且** `!replaced`(这次没有换过证书内容)。
//
// 为什么不能只看 id —— 续期时是「同一条证书记录原地换内容」,
// **id 保持不变**(这正是用 `sslID` 更新的好处:站点对它的引用不会断)。
// 于是 `cur.sslId === sslId` 恒为 true → 每个站点都被判「已生效」跳过。
//
// 那「跳过」到底有没有风险?取决于换内容那一步有没有顺带刷新站点的
// `www/sites/<域名>/ssl/{fullchain,privkey}.pem`:
// · 走 **`/websites/ssl/upload` + `sslID` → 会刷新**(实测 5 个站点
// 的 mtime 全部前进)。也就是这条路径下「跳过」本来是安全的。
// · 但如果哪天又用回 `/websites/ssl/update`(结构体里根本没有
// certificate/privateKey,内容被静默丢弃 → 续期完全空转),
// 「跳过」就会掩盖问题:日志全是「跳过(已生效)」、任务报成功,
// 而线上 nginx 端到端仍是**旧证书**,一直到旧证书过期才暴露。
// 所以这里刻意保留一次强制重绑:既让 nginx 重新加载、又确认站点配置
// 确实指向这张证书。证书链路的静默失败 = 站点直接不可访问,
// 这点冗余代价(每个站点一次 graceful reload,一年 6 次左右)是值得的。
if (cur.enable && cur.sslId === sslId && !replaced) {
log(`1Panel:网站 ${key} 已经在用这张证书,跳过`);
details.push(`跳过 ${key}(已生效)`);
continue;
}
if (replaced && cur.enable && cur.sslId === sslId) {
log(`1Panel:网站 ${key} 指向的证书 #${sslId} 内容刚被更新,强制重绑以刷新 ssl/ 文件`);
}
// ★ 保留原有配置:HTTP→HTTPS 跳转、协议版本、算法、HSTS —— 只换证书
const body: Record<string, unknown> = {
websiteId: site.id,
type: 'existed',
sslId,
// ★★★ 字段名是 **`websiteSSLId`**,不是 `sslId`(2026-10-06 实测,阻断了一整轮部署)。
//
// v2.1.13 的 `dto/request/website.go`:
// type WebsiteHTTPSOp struct {
// WebsiteID uint `json:"websiteId" validate:"required"`
// WebsiteSSLID uint `json:"websiteSSLId"` // ← 这里
// Type string `json:"type" validate:"oneof=existed auto manual"`
// ...
// }
//
// 发 `sslId` 时 Go 静默忽略它 → `WebsiteSSLID` 保持零值 0 →
// 服务端 `websiteSSLRepo.GetFirst(WithByID(0))` 查不到行 →
// 回 **HTTP 200 + code 500「服务错误: record not found」**。
//
// 为什么特别坑:
// · 报错文案是数据库层的 `record not found`,完全没有「字段名不对」的线索;
// 直觉会去怀疑「证书 id 不存在」或「站点 id 不对」,而那两处当时都是对的。
// · `type:'existed'` 是**合法取值**,所以 validate 过了,请求进到了业务层才炸。
// · 1Panel 自己的 dto 里同一个语义有三种写法:
// `WebsiteHTTPSOp` → `websiteSSLId`
// `BatchWebsiteHttps`→ `websiteSSLId`
// `WebsiteCreate.SSLConfig` → `websiteSSLID`(大写 ID)
// 照抄 `GetWebsiteHTTPSOp` 的读法或凭感觉写 `sslId` 都会踩。
// 实测对照:发 `sslId` → 500 record not found;发 `websiteSSLId` → 200。
websiteSSLId: sslId,
enable: true,
httpConfig: cur.httpConfig || 'HTTPToHTTPS',
SSLProtocol: cur.SSLProtocol?.length ? cur.SSLProtocol : ['TLSv1.2', 'TLSv1.3'],
+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 可能过期/失同步,
// 线上才决定读者会不会看到证书过期。
+15
View File
@@ -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,
},
],
+71
View File
@@ -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);
+53 -1
View File
@@ -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 要用)`);
+14 -10
View File
@@ -134,10 +134,15 @@ custom_domain = true
# 定时任务(合并 rss-robot 的抓取):
# 0 * * * * RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
# 17 3 * * * 评论 GC(限流/验证码清理 + healthz 缓存刷新)
# 10 4 * * * 证书续期检查(探针查剩余天数,≤30 天才真正签发+部署)
#
# ★ 2026-10-06 起,证书续期 cron(原 `10 4 * * *`)已移除:
# 签发 + 部署整条链路搬到了国内机的 Docker 容器 `cn-certkeeper`
# (见 deploy/cn-certkeeper/、函数版证书管家-方案.md 第九章)。
# 两处同时跑会**重复签发**(白耗 CA 配额)并争抢同一批站点部署,
# 所以 Worker 侧只保留**只读**能力:证书状态查询 / 自检 / 后台。
# ---------------------------------------------------------------------------
[triggers]
crons = ["17 3 * * *", "0 * * * *", "10 4 * * *"]
crons = ["17 3 * * *", "0 * * * *"]
# ---------------------------------------------------------------------------
# 资源上限 —— ★ 免费版不支持,必须注释掉,否则**部署直接被拒**
@@ -148,14 +153,13 @@ crons = ["17 3 * * *", "0 * * * *", "10 4 * * *"]
# 注意:免费版不是「忽略」这个字段,而是**整个部署失败**,
# 所以只要账号还在 Free plan,这段就必须保持注释。
#
# ★ 同时这也意味着:免费版硬顶 CPU 10ms,**证书签发功能跑不起来**
# (一次 ECDSA 签名 + 手写 DER 的 CSR 远不止 10ms)。
# 换句话说:ACME 签发/续期这条链路需要 Workers Paid($5/月)。
# 探针 / 环境自检 / 手动查询这些轻量功能在免费版完全可用。
#
# 升级到 Paid 后,把下面两行放开即可(cron 默认 30s,Paid 可到 5min):
# [limits]
# cpu_ms = 60000
# ★ 免费版硬顶 CPU 10ms/请求(cron 同样 10ms)。实测一次签发的密码学
# 工作量:importKey 126µs + sign 84µs,一次签发约 10~12 次 JWS ——
# 优化前 ≈3.3ms、缓存 CryptoKey 后 ≈1.4ms;乘 2~3 倍保守系数后
# 是 6.6~10ms vs 2.8~4.2ms,属于**擦边**而非「必然爆」。
# 但既然国内机本来就在跑,把签发搬过去更划算(还顺带绕开了
# 「CF 出口 IP 进不了 1Panel 白名单」「Worker 拿不到证书正文」两个坑)。
# → 决策见 函数版证书管家-方案.md 第九章(B+C 组合,不买 Paid)。
# ---------------------------------------------------------------------------
[observability]