diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index e5ba9961..d4e6708e 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -1979,7 +1979,7 @@ async function viewSSL() { '' + '' + '' + - '' + + '' + '' + '' + (ov.mailEnabled ? '' : '邮件未配置')) + @@ -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 = '签发中…'; - 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, + '

签发 + 部署整条链路现在跑在国内机的 Docker 容器 cn-certkeeper' + + '(只绑 127.0.0.1,不对外)。这个页面只做只读监控与配置。

' + + '

在内侧机上执行:

' + + '
' +
+      esc(cmd) + '
' + + '

· 只问「该不该续」:GET /renew-check
' + + '· 看凭据是否齐备:GET /preflight
' + + '· 看每个域名的真实剩余天数:GET /status

' + + '
' + + '
'); return; } diff --git a/blog-admin/src/index.ts b/blog-admin/src/index.ts index 981aa86a..d5aee03d 100644 --- a/blog-admin/src/index.ts +++ b/blog-admin/src/index.ts @@ -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 { 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); diff --git a/blog-admin/src/lib/acme.ts b/blog-admin/src/lib/acme.ts index 47fd385c..bb1725dd 100644 --- a/blog-admin/src/lib/acme.ts +++ b/blog-admin/src/lib/acme.ts @@ -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)[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 { const base: Record = { 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, payload: unknown): Promise { 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 { + private async post( + url: string, + payload: unknown, + opts: { useJwk?: boolean; retries?: number; accept?: string } = {}, + ): Promise { 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 { - 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 { + 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 { + 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 { + /** + * 等订单进入 `ready` —— 只有 `ready` 才允许 finalize(RFC 8555 §7.1.1)。 + * + * 正常路径下(授权是这次新验的)订单通常**瞬间**就是 ready,这个函数会 + * 一次就返回;真正需要它的是「授权被服务端复用、订单仍是 pending」那种情况。 + * + * `valid` 视作异常:说明订单在我们还没提交 CSR 的情况下就完成了 —— + * 那时我们手里没有与之匹配的私钥,拿着一张文不对题的证书比直接报错危险得多。 + */ + private async waitOrderReady(url: string, timeoutMs: number): Promise { 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 { 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 { * signature BIT STRING * } */ -async function makeCsr(keyPair: CryptoKeyPair, domains: string[]): Promise { +async function makeCsrImpl(keyPair: CryptoKeyPair, domains: string[]): Promise { 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 { + return makeCsrImpl(keyPair, domains); +} + // ---- 极简 DER 编码器 ---- function concat(...arrs: Uint8Array[]): Uint8Array { diff --git a/blog-admin/src/lib/certissue.ts b/blog-admin/src/lib/certissue.ts index bdb4ef89..d29f1e04 100644 --- a/blog-admin/src/lib/certissue.ts +++ b/blog-admin/src/lib/certissue.ts @@ -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 { 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 { 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 = { 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 { +async function probeLocal(h: string, timeoutMs: number, connectTo?: string): Promise { + const dialHost = connectTo || h; return new Promise((resolve) => { let settled = false; let sock: { destroy: () => void } | null = null; @@ -148,7 +164,9 @@ async function probeLocal(h: string, timeoutMs: number): Promise { 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 { diff --git a/blog-admin/src/lib/certstore.ts b/blog-admin/src/lib/certstore.ts index 9c03bbb9..48ff7a87 100644 --- a/blog-admin/src/lib/certstore.ts +++ b/blog-admin/src/lib/certstore.ts @@ -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 } : {}), }; } diff --git a/blog-admin/src/lib/deployer.ts b/blog-admin/src/lib/deployer.ts index c063354c..9c54e4be 100644 --- a/blog-admin/src/lib/deployer.ts +++ b/blog-admin/src/lib/deployer.ts @@ -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 { 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 { + 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 { - // ① 查同名(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>('/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( + 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 = { 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 { 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 = { 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'], diff --git a/blog-admin/src/routes/ssl.ts b/blog-admin/src/routes/ssl.ts index ff656a6f..76958021 100644 --- a/blog-admin/src/routes/ssl.ts +++ b/blog-admin/src/routes/ssl.ts @@ -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 { const seen = new Set(); 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 { 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 { 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 { 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 { 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: " http://127.0.0.1:8019/renew', + ); } /** 只看「该不该续期」,不签发 —— 给 UI 的「检查」按钮用,秒回 */ @@ -641,8 +657,10 @@ export async function certRenewCheck(ctx: Ctx): Promise { 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 可能过期/失同步, // 线上才决定读者会不会看到证书过期。 diff --git a/blog-admin/tools/certkeeper-config.mjs b/blog-admin/tools/certkeeper-config.mjs index 1dbdec3b..8885f637 100644 --- a/blog-admin/tools/certkeeper-config.mjs +++ b/blog-admin/tools/certkeeper-config.mjs @@ -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, }, ], diff --git a/blog-admin/tools/selftest-csr.mjs b/blog-admin/tools/selftest-csr.mjs new file mode 100644 index 00000000..a7a443c7 --- /dev/null +++ b/blog-admin/tools/selftest-csr.mjs @@ -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); diff --git a/blog-admin/tools/selftest-ssl.mjs b/blog-admin/tools/selftest-ssl.mjs index 39a9d321..ccb61d44 100644 --- a/blog-admin/tools/selftest-ssl.mjs +++ b/blog-admin/tools/selftest-ssl.mjs @@ -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 要用)`); diff --git a/blog-admin/wrangler.toml b/blog-admin/wrangler.toml index 58892a4a..5f676822 100644 --- a/blog-admin/wrangler.toml +++ b/blog-admin/wrangler.toml @@ -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] diff --git a/deploy/cn-certkeeper/src/redeploy.mjs b/deploy/cn-certkeeper/src/redeploy.mjs new file mode 100644 index 00000000..f1a06ce8 --- /dev/null +++ b/deploy/cn-certkeeper/src/redeploy.mjs @@ -0,0 +1,70 @@ +/** + * 只重跑「部署」这一步 —— 复用 KV 里**已经签好的**证书,不碰 ACME。 + * + * 为什么需要它(2026-10-06 加): + * 签发的成败与部署的成败是**两件独立的事**。一旦部署侧出问题(1Panel 接口 + * 改结构、站点名对不上、面板限流…),重新走 `/renew` 会**再签一张新证书** + * —— 白白消耗 CA 配额,而证书本身根本没出问题。 + * 这个脚本直接把 KV 里的证书 + 配置里的部署目标重跑一遍。 + * + * 用法: + * docker exec cn-certkeeper node src/redeploy.mjs t-t.live + * docker exec cn-certkeeper node src/redeploy.mjs t-t.live --dry # 只看会绑哪些站点 + */ +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { FileKV } from './kv-file.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`)); + +const argv = process.argv.slice(2); +const NAME = argv.find((a) => !a.startsWith('--')); +const DRY = argv.includes('--dry'); +if (!NAME) { + console.error('用法:node src/redeploy.mjs <域名> [--dry]'); + process.exit(2); +} + +const kv = new FileKV(process.env.DATA_DIR || '/data'); +const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' }; + +const { loadConfig, getCert, getAccess } = lib('certstore'); +const { makeDeployer } = lib('deployer'); + +const cfg = await loadConfig(env); +const d = cfg.domains.find((x) => x.name === NAME); +if (!d) { + console.error(`配置里没有域名「${NAME}」(现有:${cfg.domains.map((x) => x.name).join(', ')})`); + process.exit(2); +} +const rec = await getCert(env, NAME); +if (!rec?.cert || !rec?.key) { + console.error(`KV 里没有「${NAME}」的证书内容(先跑一次签发)`); + process.exit(2); +} +console.log(`域名 ${NAME}:证书到期 ${new Date(rec.expireAt).toISOString()}(${Math.round((rec.expireAt - Date.now()) / 86400000)} 天)`); +console.log(`部署目标 ${(d.deploy || []).join(', ')};1Panel 站点 ${(d.one_panel_sites || []).join(', ')}`); +if (DRY) process.exit(0); + +for (const target of d.deploy || []) { + const credName = target === '1panel' ? '1panel-cn' : target; + const cred = await getAccess(env, credName); + if (!cred) { + console.log(` ${target}: ★ 找不到凭据「${credName}」`); + continue; + } + try { + const dp = makeDeployer(cred); + const res = await dp.deploy( + { domain: NAME, cert: rec.cert, key: rec.key, notAfter: rec.expireAt }, + { dogecloudDomains: d.dogecloud_domains, onePanelSites: d.one_panel_sites, log: (m) => console.log(` [${target}] ${m}`) }, + ); + console.log(` ${target}: 完成 —— ${res.details.join(';')}`); + } catch (e) { + console.log(` ${target}: ★ 失败 —— ${e instanceof Error ? e.message : e}`); + process.exitCode = 1; + } +} diff --git a/deploy/cn-certkeeper/src/renew-one.mjs b/deploy/cn-certkeeper/src/renew-one.mjs new file mode 100644 index 00000000..73606005 --- /dev/null +++ b/deploy/cn-certkeeper/src/renew-one.mjs @@ -0,0 +1,75 @@ +/** + * 只对**单个域名**做「签发 + 部署」,别的域名一律不碰。 + * + * 为什么需要它(2026-10-06 加): + * 容器只有一个 `POST /renew`,而它调的是 `renewAll` —— **没有域名过滤**。 + * 想单独把一个域名提前换掉时,`{force:true}` 会把配置里**所有**域名 + * 一起重签一遍:多烧 2 个 CA 订单,还会把本来好好的 t-t.live / 200181.xyz + * 一起换掉、增加无关的线上变更面。这个脚本把「只动一个域名」变成可选操作。 + * + * 与 `redeploy.mjs` 的分工: + * · `redeploy.mjs` —— **复用已签好的证书**,只重跑部署(不碰 ACME) + * · `renew-one.mjs` —— 真的去 CA **签一张新的**,然后部署 + * + * 用法: + * docker exec cn-certkeeper node src/renew-one.mjs usj.cc + * docker exec cn-certkeeper node src/renew-one.mjs usj.cc --no-deploy # 只签不部署 + * docker exec cn-certkeeper node src/renew-one.mjs usj.cc --dry # 只看会做什么 + * + * ★ 默认 `force: true`(线上还剩 60 天也要换)—— 这正是本脚本的用途。 + * 想走「按阈值判断」请直接调 /renew。 + */ +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { FileKV } from './kv-file.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`)); + +const argv = process.argv.slice(2); +const NAME = argv.find((a) => !a.startsWith('--')); +const DRY = argv.includes('--dry'); +const NO_DEPLOY = argv.includes('--no-deploy'); +if (!NAME) { + console.error('用法:node src/renew-one.mjs <域名> [--no-deploy] [--dry]'); + process.exit(2); +} + +const kv = new FileKV(process.env.DATA_DIR || '/data'); +const env = { + RSS_KV: kv, + TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), + // 留空 → 探针直接走本机 node:tls,不再绕 editor-api + EDITOR_API_BASE: '', + EDITOR_TOKEN: '', +}; + +const { loadConfig } = lib('certstore'); +const { issueDomain, RENEW_BEFORE_DAYS } = lib('certissue'); + +const cfg = await loadConfig(env); +const d = cfg.domains.find((x) => x.name === NAME); +if (!d) { + console.error(`配置里没有域名「${NAME}」(现有:${cfg.domains.map((x) => x.name).join(', ')})`); + process.exit(2); +} + +console.log(`域名 ${NAME}`); +console.log(` SAN : ${(d.san || []).join(', ')}`); +console.log(` DNS 凭据 : ${d.dns}`); +console.log(` 部署目标 : ${(d.deploy || []).join(', ')}`); +console.log(` 1Panel 站点: ${(d.one_panel_sites || []).join(', ')}`); +console.log(` 多吉云域名 : ${(d.dogecloud_domains || []).join(', ')}`); +console.log(` 续期阈值 : ${RENEW_BEFORE_DAYS} 天(本脚本 force,忽略阈值)`); +console.log(` 部署 : ${NO_DEPLOY ? '否(--no-deploy)' : '是'}`); +if (DRY) { + console.log('\n[dry] 不实际签发。'); + process.exit(0); +} + +const out = await issueDomain(env, d, { force: true, noDeploy: NO_DEPLOY, by: 'renew-one' }); +console.log('\n=== 结果 ==='); +console.log(JSON.stringify(out, null, 2)); +if (!out.ok) process.exitCode = 1; diff --git a/deploy/cn-certkeeper/src/rollback-dogecloud.mjs b/deploy/cn-certkeeper/src/rollback-dogecloud.mjs new file mode 100644 index 00000000..87a40675 --- /dev/null +++ b/deploy/cn-certkeeper/src/rollback-dogecloud.mjs @@ -0,0 +1,90 @@ +/** + * 应急回退:把多吉云 CDN 的加速域名绑回**指定的证书 id**。 + * + * 什么时候用:换了新证书之后 CDN 侧表现异常(比如最终端不吃 ECDSA), + * 需要立刻把域名绑回上一代证书恢复服务。 + * + * 用法: + * docker exec cn-certkeeper node src/rollback-dogecloud.mjs usj.cc 40948 + * docker exec cn-certkeeper node src/rollback-dogecloud.mjs usj.cc # 只列候选 + * + * 绑定的域名取自**配置里该域名的 `dogecloud_domains`**,所以不会误伤别的域名。 + */ +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { FileKV } from './kv-file.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`)); + +const argv = process.argv.slice(2); +const NAME = argv.find((a) => !a.startsWith('--')); +const WANT_ID = argv.filter((a) => !a.startsWith('--'))[1]; +if (!NAME) { + console.error('用法:node src/rollback-dogecloud.mjs <域名> [证书id]'); + process.exit(2); +} + +const kv = new FileKV(process.env.DATA_DIR || '/data'); +const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' }; +const { loadConfig, getAccess } = lib('certstore'); +const { makeDeployer } = lib('deployer'); + +const cfg = await loadConfig(env); +const d = cfg.domains.find((x) => x.name === NAME); +if (!d) { console.error(`配置里没有域名「${NAME}」`); process.exit(2); } +const domains = (d.dogecloud_domains || []).map((s) => s.trim()).filter(Boolean); +if (!domains.length) { console.error('该域名没配 dogecloud_domains'); process.exit(2); } + +const cred = await getAccess(env, 'dogecloud'); +const dp = makeDeployer(cred); + +// 复用部署器的私有 api:通过 re-deploy 的 ping 拿不到列表,这里直接照签名规则自己调。 +// (把 deployer 当黑盒的话拿不到 list,所以就地实现一次只读列举) +const nc = await import('node:crypto'); +const enc = new TextEncoder(); +async function dapi(path_, body) { + const bodyStr = body === null ? '' : JSON.stringify(body); + const key = await nc.subtle.importKey('raw', enc.encode(cred.secretKey), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); + const sig = Buffer.from(await nc.subtle.sign('HMAC', key, enc.encode(`${path_}\n${bodyStr}`))).toString('hex'); + const r = await fetch('https://api.dogecloud.com' + path_, { + method: 'POST', + headers: { Authorization: `TOKEN ${cred.accessKey}:${sig}`, 'Content-Type': 'application/json', Accept: 'application/json' }, + body: bodyStr || undefined, + }); + return JSON.parse(await r.text()); +} + +const cl = await dapi('/cdn/cert/list.json', {}); +const certs = cl?.data?.certs || []; +const dl = await dapi('/cdn/domain/list.json', {}); +const inUse = new Map((dl?.data?.domains || []).map((x) => [x.name, x.cert_id])); + +console.log(`域名 ${NAME};将操作的加速域名:${domains.join(', ')}\n`); +console.log('当前绑定:'); +for (const n of domains) console.log(` ${n.padEnd(20)} cert_id=${inUse.get(n)}`); +console.log('\n候选证书(域名集含上述域名的):'); +const want = new Set(domains.map((s) => s.toLowerCase())); +for (const c of certs) { + const have = new Set((c.domains || []).map((x) => String(x.name).toLowerCase())); + let covers = true; + for (const w of want) if (!have.has(w)) { covers = false; break; } + if (!covers) continue; + console.log(` #${String(c.id).padEnd(8)} note=${JSON.stringify(c.note)} issue=${c.issueDate} expire=${c.expireText} SAN=${JSON.stringify(c.info?.SAN || [])} algo=${c.info?.encryptAlgorithm || '?'}`); +} + +if (!WANT_ID) { + console.log('\n(未指定证书 id,只列举,不做改动)'); + console.log('回退用法:node src/rollback-dogecloud.mjs ' + NAME + ' <上面的某个 id>'); + process.exit(0); +} + +const id = Number(WANT_ID); +if (!certs.some((c) => Number(c.id) === id)) { console.error(`\n★ 证书 #${id} 不在列表里,拒绝执行`); process.exit(1); } +for (const n of domains) { + const r = await dapi('/cdn/cert/bind.json', { id, domain: n }); + console.log(` bind ${n} → #${id} code=${r.code} msg=${r.msg || ''}`); +} +console.log('\n回退完成。'); diff --git a/deploy/cn-certkeeper/src/selfcheck-acme.mjs b/deploy/cn-certkeeper/src/selfcheck-acme.mjs new file mode 100644 index 00000000..9d0454f2 --- /dev/null +++ b/deploy/cn-certkeeper/src/selfcheck-acme.mjs @@ -0,0 +1,102 @@ +/** + * ACME 账户自检 —— 只跑「读目录 → 注册/找回账户」,**不签发、不写 DNS、不部署**。 + * + * 为什么要有它(2026-10-06 加): + * 续期链路有三大层,失败时的报错很容易串味 —— CA 层 / DNS 层 / 部署层。 + * 最坑的一次是:LiteSSL 回 + * `403 {"detail":"newAccount JWS signature is invalid"}` + * 报错写得像「你的签名算法错了」,实际根因是**我们把带私钥 `d` 的 JWK 原样 + * 塞进了 protected.jwk**(RFC 8555 §6.2 要求只放公钥)。 + * 有了这个脚本,这类问题 3 秒定位,不必拿一次真签发去赌。 + * + * 用法(容器内): + * docker exec cn-certkeeper node src/selfcheck-acme.mjs + * docker exec cn-certkeeper node src/selfcheck-acme.mjs --reuse # 复用已存账户,不新建 + * + * ★ 默认会**新建一把账户密钥**去注册(也就是在 CA 上多一个账户)。 + * 如果只想验证「已存账户还能不能签」,用 --reuse。 + * 加 --full 才会真去签一张(会给 `_acme-challenge` 写 TXT,别乱跑)。 + */ +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { FileKV } from './kv-file.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const LIB_DIR = path.resolve(HERE, '../lib'); +const require = createRequire(import.meta.url); +const lib = (name) => require(path.join(LIB_DIR, `${name}.js`)); + +const DATA_DIR = process.env.DATA_DIR || '/data'; +const argv = process.argv.slice(2); +const REUSE = argv.includes('--reuse'); +const CA_NAME = (() => { + const i = argv.indexOf('--ca'); + return i >= 0 && argv[i + 1] ? argv[i + 1] : 'litessl'; +})(); + +const kv = new FileKV(DATA_DIR); +const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' }; + +const mask = (s, n = 8) => (s ? `${String(s).slice(0, n)}…(${String(s).length})` : '(空)'); + +(async () => { + const { AcmeClient, newAccountKey } = lib('acme'); + const { getAccess } = lib('certstore'); + + const rec = await getAccess(env, CA_NAME); + if (!rec) { + console.error(`★ 找不到凭据「${CA_NAME}」—— 先确认数据目录里 certkeeper/access/${CA_NAME}.kv 存在`); + process.exit(2); + } + const url = String(rec.directoryUrl || ''); + const eabKid = String(rec.eabKid || ''); + const eabHmac = String(rec.eabHmacKey || ''); + console.log(`CA 凭据「${CA_NAME}」`); + console.log(` directoryUrl = ${url}`); + console.log(` eabKid = ${mask(eabKid)}`); + console.log(` eabHmacKey = ${mask(eabHmac)}(长度 ${eabHmac.length})`); + if (!url) { console.error('★ 缺少 directoryUrl'); process.exit(2); } + + // ---- ① 目录与 EAB 要求 ---- + const probe = new AcmeClient(url, { jwk: {}, kid: '' }); + const needEab = await probe.externalAccountRequired(); + console.log(`\n① 目录可达;externalAccountRequired = ${needEab}`); + if (needEab && !(eabKid && eabHmac)) { + console.error('★ 这个 CA 强制 EAB,但凭据里 eabKid / eabHmacKey 不全'); + process.exit(2); + } + + // ---- ② 账户 ---- + let account; + if (REUSE) { + const raw = await env.RSS_KV.get('certkeeper:acme-account'); + if (!raw) { console.error('★ 数据目录里没有已存账户(certkeeper/acme-account.kv),去掉 --reuse 再跑'); process.exit(2); } + account = JSON.parse(raw); + console.log(`\n② 复用已存账户:kid = ${account.kid}`); + if (account.directoryUrl !== url) { + console.error(`★ 已存账户是给别的 CA 注册的(${account.directoryUrl}),与新 URL 不符 —— 会被判无效`); + process.exit(2); + } + } else { + const jwk = await newAccountKey(); + const c = new AcmeClient(url, { jwk, kid: '' }); + // 只打印公开部分,顺带验证「发出去的确实是公钥」 + const { kty, crv, x, y } = jwk; + console.log(`\n② 新建账户密钥:kty=${kty} crv=${crv} x.len=${(x || '').length} y.len=${(y || '').length}`); + console.log(' (注意:注册时发出去的 jwk 只应是 kty/crv/x/y 四个成员)'); + const kid = await c.registerAccount([], eabKid && eabHmac ? { kid: eabKid, hmacKeyB64: eabHmac } : undefined); + account = { jwk, kid, directoryUrl: url }; + console.log(` 注册成功 ✓ kid = ${kid}`); + } + + // ---- ③ 用该账户做一次**只读**的 POST-as-GET(不产生任何订单)---- + const c2 = new AcmeClient(url, { jwk: account.jwk, kid: account.kid }); + const acctRes = await c2['postAsGet'](account.kid); + const acct = await acctRes.json(); + console.log(`\n③ 用 kid 签名访问账户资源:HTTP ${acctRes.status},status=${acct.status}`); + console.log('\n结论:CA 账户层(目录 + 注册 + kid 签名)**全部打通**。'); +})().catch((e) => { + console.error('\n★ 失败:', e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/deploy/cn-certkeeper/src/serve.mjs b/deploy/cn-certkeeper/src/serve.mjs index 947fd78d..55fe5569 100644 --- a/deploy/cn-certkeeper/src/serve.mjs +++ b/deploy/cn-certkeeper/src/serve.mjs @@ -102,13 +102,16 @@ async function checkAll() { out.push({ name: d.name, skipped: true, reason: '域名已停用' }); continue; } - const host = d.san.find((s) => !s.startsWith('*.')) || d.name; + const host = d.probe_sni || d.san.find((s) => !s.startsWith('*.')) || d.name; const t0 = Date.now(); - const live = await probeTls(host, PROBE_TIMEOUT_MS); + // ★ 配了 probe_connect 就连源站 IP(SNI 仍是 host)—— 域名挂在 CDN/边缘 + // 加速后面时,公网握手量的边缘证书,会让续期判定永远「还很新」。 + const live = await probeTls(host, PROBE_TIMEOUT_MS, undefined, undefined, d.probe_connect); const left = daysLeft(live.notAfter); out.push({ name: d.name, host, + probeVia: d.probe_connect ? `${d.probe_connect}(SNI ${host})` : `公网 ${host}:443`, ok: !!live.ok, probeMs: Date.now() - t0, notAfter: live.notAfter ? new Date(live.notAfter).toISOString() : null, diff --git a/deploy/cn-certkeeper/src/txt-inspect.mjs b/deploy/cn-certkeeper/src/txt-inspect.mjs new file mode 100644 index 00000000..6652346a --- /dev/null +++ b/deploy/cn-certkeeper/src/txt-inspect.mjs @@ -0,0 +1,51 @@ +/** + * 只读检查 `_acme-challenge.200181.xyz` 下残留的 TXT(失败签发可能留下脏记录)。 + * 用法: + * docker exec cn-certkeeper node src/txt-inspect.mjs 200181.xyz # 只列 + * docker exec cn-certkeeper node src/txt-inspect.mjs 200181.xyz --clean # 清空该名字下所有 TXT + */ +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { FileKV } from './kv-file.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`)); + +const argv = process.argv.slice(2); +const NAME = argv.find((a) => !a.startsWith('--')); +const CLEAN = argv.includes('--clean'); +if (!NAME) { + console.error('用法:node src/txt-inspect.mjs <域名> [--clean]'); + process.exit(2); +} + +const kv = new FileKV(process.env.DATA_DIR || '/data'); +const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' }; +const { loadConfig, getAccess } = lib('certstore'); +const { makeDnsProvider } = lib('dnsprovider'); + +const cfg = await loadConfig(env); +const d = cfg.domains.find((x) => x.name === NAME); +if (!d) { console.error(`配置里没有域名「${NAME}」`); process.exit(2); } + +const rec = await getAccess(env, d.dns); +if (!rec) { console.error(`找不到 DNS 凭据「${d.dns}」`); process.exit(2); } +const dns = makeDnsProvider(rec); +console.log(`DNS 凭据 ${d.dns} → ${dns.kind}`); + +const fqdn = `_acme-challenge.${NAME}`; +const vals = await dns.listTxt(fqdn); +console.log(`\n${fqdn} 下的 TXT 记录 ${vals.length} 条:`); +for (const v of vals) console.log(` ${v.slice(0, 24)}… (${v.length} 字符)`); + +if (CLEAN) { + for (const v of vals) { + await dns.delTxt(fqdn, v); + console.log(` 已删 ${v.slice(0, 16)}…`); + } + console.log(`\n清理后剩余:${(await dns.listTxt(fqdn)).length} 条`); +} else if (vals.length) { + console.log('\n(加了 --clean 才真的删)'); +} diff --git a/editor-api/server.mjs b/editor-api/server.mjs index 7fece193..f74bcb6d 100644 --- a/editor-api/server.mjs +++ b/editor-api/server.mjs @@ -436,12 +436,20 @@ async function rssProxy(req, res, url) { * `valid_from` / `valid_to`(形如 `Dec 7 06:59:59 2026 GMT`,日号前两个空格), * **不是** `notBefore` / `notAfter`。 */ -function sslProbe(host, timeoutMs = 8000) { +function sslProbe(host, timeoutMs = 8000, connectTo) { return new Promise((resolve) => { const h = String(host || '').trim().toLowerCase(); if (!h || !/^[a-z0-9.-]+$/.test(h)) { return resolve({ ok: false, error: '域名不合法' }); } + // ★ connectTo:连到**指定地址**(源站 IP),SNI 仍用 h。 + // 用途:域名挂在 CDN / 边缘加速(如腾讯云 EO)后面时,公网握手拿到的是 + // 边缘证书;只有直连源站 IP 才能量到「我们真正要部署的那张证书」。 + const dial = String(connectTo || '').trim(); + if (dial && !/^[a-z0-9.:-]+$/.test(dial)) { + return resolve({ ok: false, error: 'connect 参数不合法(只允许域名或 IP)' }); + } + const dialHost = dial || h; let settled = false; let socket = null; @@ -465,7 +473,7 @@ function sslProbe(host, timeoutMs = 8000) { try { socket = tls.connect( { - host: h, + host: dialHost, port: 443, servername: h, // SNI:同 IP 多站时必须带,否则拿到的是默认证书 // 证书过期 / 自签也要能把正文读出来 —— 探针的任务是「报告」,不是「拒绝」 @@ -811,9 +819,12 @@ const server = http.createServer(async (req, res) => { json(res, 400, { ok: false, error: '缺少 host 参数' }); return; } + // ★ connect=:直连源站(SNI 仍是 host)。用于量「源站证书」而不是 + // CDN/边缘节点那张 —— 否则续期判定会永远「还很新」。 + const connectTo = String(url.searchParams.get('connect') || '').trim(); const t0 = Date.now(); - const info = await sslProbe(host); - json(res, 200, { ...info, host, ms: Date.now() - t0 }); + const info = await sslProbe(host, 8000, connectTo || undefined); + json(res, 200, { ...info, host, connect: connectTo || null, ms: Date.now() - t0 }); return; } diff --git a/函数版证书管家-方案.md b/函数版证书管家-方案.md index 89f25865..a980ce62 100644 --- a/函数版证书管家-方案.md +++ b/函数版证书管家-方案.md @@ -571,7 +571,7 @@ private importSigningKey(): Promise { > ⚠️ 3 个手工建的 nginx vhost(`dnsapi.usj.cc` / `writeapi.usj.cc` / `vaultwarden`) > **不归 1Panel 站点管理**,证书是**文件拷贝**(指纹与 id 11 一致),与证书库记录解耦。 -### 9.7 ★ 未决冲突:t-t.live 的「两套管理」 +### 9.7 ★ 未决冲突:t-t.live 的「两套管理」 → 已定案:本项目接管 - 1Panel 证书库 `id 2 *.t-t.live` 记录的是**旧证书**(到期 2026-10-09) - 但对外 **实际服务**的是 certd 直接写进 nginx 的**新证书**(Let's Encrypt,到期 2026-12-27) @@ -582,12 +582,370 @@ private importSigningKey(): Promise { 1. 停掉 certd,本项目接管 t-t.live 全部(含 1Panel + 腾讯云 EO) 2. 本项目**放弃 t-t.live 的 1panel 部署目标**,只留 usj.cc / 200181.xyz -### 9.8 其它待决 +> **2026-10-06 晚定案:选 ①** —— 停掉 certd,本项目接管 t-t.live 全部。 -- **Worker 的证书续期 cron(`10 4 * * *`)是否停掉**:现在国内机已接管签发, - 两边同时跑会**重复签发**(浪费 CA 配额)+ 争抢部署。建议停掉 Worker 的这条 cron, - Worker 只留只读监控/后台。 -- **`usj.cc` 的 SAN 会从单名升级成通配**(线上是 `CN=usj.cc`,本项目配的是 `usj.cc;*.usj.cc`) - —— 需确认是否接受。 -- **`dnsapi.usj.cc` 上 HTTPS**:nginx 反代已配好(HTTP 通),证书未上。 - 当前 `cn-certkeeper` 只绑 `127.0.0.1:8019`,暂未走反代。 +### 9.7b ★ 接管时查清的真实现状(推翻 9.7 的部分描述) + +实地排查后,事实和上面的推测**不一样**,记下来免得后人再绕: + +| 项 | 原先以为 | **实测真相** | +|---|---|---| +| 谁在管 t-t.live | 一个 `certd` | 这台机器上**没有 certd 进程/容器**;真正的竞争者是 **certimate 容器**(`1Panel-certimate-OKCO`,7 个工作流每天 12:00 起跑) | +| `certd.t-t.live` 是什么 | certd 本体 | 只是个 nginx vhost → `127.0.0.1:6000` → **frps** 隧道到别处;`6000/7000` 是 frp 端口,与证书无关 | +| 源站证书到期 | 2026-12-27 | **2026-10-09(只剩 3 天)**。5 个站点 `ssl/` 目录里的文件 mtime 全是 `2026-07-11 21:28:03`,之后再没被更新过 | +| 公网看到的证书 | = 源站证书 | **不是**。公网走**腾讯云 EO 边缘加速**(apex 是 CNAME → `t-t.live.eo.dnse2.com`),边缘那张是 LE **12-27** | + +### 9.7c ★★ 根因:apex 上的 CNAME 让 lego 拿不到权威 NS + +certimate 的「团团证书申请工作流」**2026-10-06 04:05 执行失败**(usj.cc / 200181.xyz 同日都成功): + +``` +failed to obtain certificate: resolver: one or more domains had a problem: + [*.t-t.live: dns01: time limit exceeded: last error: + authoritative nameservers: [zone=t-t.live.] could not determine authoritative nameservers] +``` + +实测 `dig`: + +``` +t-t.live. 60 IN CNAME t-t.live.eo.dnse2.com. ← apex 是一条 CNAME +dig +short NS t-t.live → t-t.live.eo.dnse2.com. ← 问 NS 也回 CNAME! +``` + +`t-t.live` 用了腾讯云 EO 的 **“CNAME 接入”**(apex 直接 CNAME 到 EO), +DNSPod 的权威服务器对 apex 的 **任何**查询都返回那条 CNAME。 +lego 的 `FindZoneByFqdn` 是「从 `_acme-challenge.t-t.live` 往上问 NS」—— +走到 apex 拿到的是 CNAME 而非 NS,于是判定「拿不到权威 NS」→ 传播检查超时 → 放弃。 + +> `usj.cc`(NS = `duncan.dnspod.net` / `brady.dnspod.net`)和 +> `200181.xyz`(NS = `jobs/martha.ns.cloudflare.com`)的 apex 都没有 CNAME, +> 所以它们从没撞上这个问题 —— 这也解释了「为什么只有 t-t.live 年年失败」。 + +★ **我们的实现天然不受影响**:`acme.ts` **不做 NS 查询**,而是 +「用 DNSPod API 写字面量 `_acme-challenge.` → 固定等 30s → 通知 CA 校验 → 轮询」。 +CA 自己查 TXT 时走的是正常解析路径(`_acme-challenge` 是独立名字,不被 apex 的 CNAME 影响)。 + +### 9.7d ★ 附带发现:探针必须量「源站」而不是「公网」 + +`certprobe.probeTls` 原本只按域名做公网握手。对挂在 CDN / 边缘加速后面的域名, +量到的是**边缘证书** —— 后果分两种,都很坏: + +| 域名 | 公网探到 | 源站实际 | 后果 | +|---|---|---|---| +| `t-t.live` | 83 天(EO 的 LE 证书) | **4 天** | 续期判定**永远**「还很新」→ 源站证书悄悄过期,站点挂掉 | +| `200181.xyz` | 62 天(Cloudflare 代理证书) | 48 天(1Panel,TrustAsia) | 续期被推迟约 2 周,余量被吃掉 | +| `ssh.200181.xyz` | **DNS 不解析**(无公网 A 记录) | 48 天 | 探针报 `ENOTFOUND` → `daysLeft=null` → 判「必须续」→ **每天尝试续期** | + +**修复**:给 `DomainConfig` 加两个字段,属**可选、向后兼容**(不填行为不变): + +| 字段 | 作用 | +|---|---| +| `probe_connect` | 探针**连到哪个地址**(填源站 IP)。SNI 仍是域名,所以源站 nginx 能按 `server_name` 选到对的 vhost | +| `probe_sni` | 探针发出去的 **SNI**。源站上未必有与域名同名的站点 —— 例如 1Panel 里**没有** `usj.cc` 这个网站(它只是证书名),直接拿 `usj.cc` 当 SNI 会落到默认 server、拿到别的证书 | + +三个域名都配上了源站直连: + +```jsonc +usj.cc { probe_connect: '119.29.215.187', probe_sni: 'artalk.usj.cc' } // 面板无 usj.cc 站点 +t-t.live { probe_connect: '119.29.215.187', probe_sni: 't-t.live' } +200181.xyz { probe_connect: '119.29.215.187', probe_sni: 'ssh.200181.xyz' } // 面板站点名是子域 +``` + +实测(本机真 Node 跑编译产物): + +``` +t-t.live 公网 → 83 天 / 源站 → 4 天 ← 修复生效,差 79 天 +artalk.usj.cc 公网 → 62 天 / 源站 → 62 天 ← 本来就对 +ssh.200181.xyz 公网 → 解析失败 / 源站 → 48 天 ← 修复了「天天误判要续期」 +``` + +同一能力也补进了国内机 `editor-api` 的 `/ssl-probe`(新增 `&connect=` 参数), +这样 Worker 侧的**只读**页面也能显示正确的剩余天数。 + +### 9.8 待决 → 2026-10-06 晚**全部定案** + +| 事项 | 定案 | +|---|---| +| Worker 的续期 cron `10 4 * * *` | **停掉**。`wrangler.toml` 的 crons 改为 `["17 3 * * *", "0 * * * *"]`;`index.ts` 里整个 renew 分支删除 | +| Worker 的签发入口 `POST /ssl/issue` | **改成 501 硬拒绝**,响应里给出「去国内机执行」的 curl 命令;后台按钮文案改成「签发/续期(在国内机)」 | +| `usj.cc` 的 SAN 升级为 `usj.cc;*.usj.cc` | **接受**(线上原本是单名 `CN=usj.cc`,升级后一张证书同时覆盖主域与全部子域) | +| `dnsapi.usj.cc` 上 HTTPS | 仍未做;`cn-certkeeper` 只绑 `127.0.0.1:8019`,暂不走反代 | +| 停 certd / 接管 t-t.live | **certd 根本不存在**,真正的竞争者是 certimate 容器;处理方式见 §9.10 | + +### 9.9 ★★ 上线前修掉的两个 ACME 客户端真实 bug(只有打真 CA 才会现形) + +这两个 bug 从写完到上线一直藏着,原因是:**账户注册从来没成功过** +(`certkeeper:acme-account` 这个键一直是空的),所以协议层代码**一行都没真跑过**。 +本地自测 117 项全过 —— 因为它们覆盖的是鉴权矩阵 / 配置校验 / 加密往返 / 脱敏, +**不覆盖 ACME 协议交互**。 + +#### Bug ① `protected.jwk` 里带着私钥 → 403 `newAccount JWS signature is invalid` + +| | | +|---|---| +| 症状 | LiteSSL `newAccount` 回 `403 {"detail":"newAccount JWS signature is invalid"}` | +| 迷惑点 | **本地验签是通过的** —— 拿 JWK 的 x/y 造公钥验 raw `r‖s`,结果 `true`。报错文案把人往「签名算法错了」上带 | +| 真因 | 账户密钥是 `exportKey('jwk', privateKey)` 出来的,含 `d` / `key_ops` / `ext`。我们把它**原样**塞进了 `protected.jwk`:
`{"key_ops":["sign"],"ext":true,"kty":"EC","x":"…","y":"…","crv":"P-256","d":"…"}` | +| 规范 | RFC 8555 §6.2:`jwk` 字段必须是**公钥**;§7.3.4:EAB 内层 payload 同样是公钥;RFC 7517 §6.2.1:EC 公钥**只**定义 `crv/kty/x/y` | +| 修复 | 新增 `publicJwk()`,把 JWK 裁剪成该密钥类型定义的成员;`protectedHeader()` 与 EAB 的 `innerPayload` 都走它 | +| 附带 | 这也是**安全修复** —— 私钥标量 `d` 不该发给 CA | + +> 排查手法:写了个自包含脚本,走**真实编译产物**的代码路径生成 JWS, +> 再**用公钥本地验签**。本地过 → 密码学没错,问题在 JWK 的语义/内容。 +> 这一步把「签名算法错」和「JWK 内容不被接受」一刀切开,省掉大量瞎猜。 + +#### Bug ② POST-as-GET 的「空 payload」被编成了 `IiI` → 400 `Expected JWS payload message` + +| | | +|---|---| +| 症状 | LiteSSL 读账户资源 / 授权 / 订单时回 `400 {"detail":"Expected JWS payload message"}` | +| 真因 | `postAsGet()` 调 `post(url, '')` → `b64uJson('')` = base64url(JSON 的 `""`) = **`IiI`**,服务端解出来是两个字符 `"`,不是空 | +| 规范 | RFC 8555 §6.3:POST-as-GET 的 payload 必须是**零长度的八位字节串**。既不是 JSON `null`(那是「让服务端删字段」),也不是 JSON `""` | +| 修复 | `postAsGet()` 改成传 `undefined`;`signJws()` 里 `payload === undefined` 走空串分支(`p = ''`) | +| 为什么以前没炸 | **ZeroSSL 容忍 `IiI`**(实测 POST-as-GET 回 200 valid),LiteSSL 严格。换 CA 才把它照出来 | + +修复后实测:`postAsGet` 发出去的 `payload = ""`,解码长度 **0 字节**。 + +### 9.10 ★★ LiteSSL 账户接管(EAB 是一次性的) + +#### 两个事实先纠正 + +1. **目录地址错了**。我们配的是 `https://acme.trustasia.com/acme/v2/directory`, + 而 certimate 数据库里真实用的是 **`https://acme.litessl.com/acme/v2/directory`**。 + 打错的目录会回 `403 externalAccountBinding kid is not valid` —— + **EAB kid 是绑定到具体 CA 目录/账户的**,地址不对,kid 自然不认。 + (另有两种写法 `…/v2/DV90/directory` 是 CertCloud 系的,走 404。) +2. **EAB 只能用一次**。换到正确目录后回的是 + `400 externalAccountRequired {"detail":"External account binding has already been used"}` + —— certimate 在 2026-02 已经用这条 EAB 注册过账户了。 + +#### 处置:继承 certimate 的账户(而不是再要一条新 EAB) + +「接管」最干净的做法就是把**它已经注册好的账户过户过来** —— 同一个 CA 账户, +不必打扰用户去 FreeSSL 控制台新建 EAB。 + +从 certimate `data.db` 的 `acme_accounts` 表取(`ca='litessl'` 只有一条): + +``` +kid = https://acme.litessl.com/acme/v2/acct/NjAuzljPMeTgYARuIduk3w +私钥 = -----BEGIN EC PRIVATE KEY-----(SEC1,P-256) +email = imql@qq.com +``` + +过户步骤(都跑在国内机): +1. 宿主机 `python3` 抽 `data.db` 的 `acme_accounts` 行 → `/tmp/litessl-acct.json`(600) +2. `docker cp` 进容器,容器里 `createPrivateKey(pem).export({format:'jwk'})` 转 JWK +3. 写 `certkeeper:acme-account`(= `{jwk, kid, directoryUrl}`,就是 `getAcmeAccount` 认的键位) +4. **顺带修 `litessl` 凭据的 `directoryUrl`** 为正确地址 —— + 否则 `getAcmeAccount` 里 `saved.directoryUrl === directoryUrl` 判不等,会去重新注册 +5. 清掉宿主机/容器里的私钥临时文件 + +验收: + +``` +② 复用已存账户:kid = https://acme.litessl.com/acme/v2/acct/NjAuzljPMeTgYARuIduk3w +③ 用 kid 签名访问账户资源:HTTP 200,status=valid +``` + +> 为此给容器加了个常驻诊断工具 **`deploy/cn-certkeeper/src/selfcheck-acme.mjs`**: +> 只跑「读目录 → 注册/复用账户 → POST-as-GET 验 kid」,**不签发、不写 DNS、不部署**。 +> 以后再遇到「续期不动」,先跑它,一刀切开是 CA 账户层还是 DNS/部署层。 +> ```bash +> docker exec cn-certkeeper node src/selfcheck-acme.mjs --reuse # 验已存账户 +> docker exec cn-certkeeper node src/selfcheck-acme.mjs --ca zerossl # 换 CA 对照 +> ``` + +### 9.11 ★★ 定案:`ssl/update` **不物化**站点文件,`ssl/upload`+`sslID` 才会 + +`OnePanelDeployer.deploy()` 的幂等捷径是:读 `/websites/{id}/https`, +若 `cur.enable && cur.sslId === sslId` 就**跳过绑定**(避免多余的 nginx reload)。 + +风险在于:站点 `ssl/{fullchain,privkey}.pem` 是 1Panel 从**证书库**物化出来的, +如果「改记录内容」**不会**重新物化,那么**第二次续期就会静默失败** —— +库里证书是新的,站点文件还是旧的。 + +#### 实测(`id=13`,5 个 t-t.live 站点) + +| 调用 | 返回 | 5 个站点 `ssl/*.pem` 的 mtime / md5 | +|---|---|---| +| `POST /websites/ssl/update`(证书内容原样回传) | **200 success** | **一个都没变** | +| `POST /websites/ssl/upload` + `sslID=13`(>0) | 200 success | **全部刷新** | + +结论:**1Panel 的 `ssl/update` 会静默空转** —— 只改库记录的元数据, +不碰站点目录下的 PEM。空转还不可见,因为接口返回 `success`。 + +#### 三个接口的正确用途(别混) + +| 接口 | 用途 | 陷阱 | +|---|---|---| +| `POST /websites/ssl/update` | 改 **ACME 申请设置**(domains / autoRenew / DNS 账户) | 结构体 `WebsiteSSLUpdate` **没有** `certificate`/`privateKey` 字段,传了被静默丢弃;`domains` 只从 `otherDomains` 取,不传就**清空**;还会顺带把 `autoRenew` 置 false | +| `POST /websites/ssl/upload` + `sslID > 0` | **换证书内容**(原地更新) | 走 `Upload()`:`SSLID>0` → 取记录 → 用 `PrivateKey`/`Certificate` 覆盖 → **重算** `ExpireDate/StartDate/Type/PrimaryDomain/domains` → `UpdateSSLConfig()` → 重新物化站点文件 | +| `POST /websites/ssl/upload`(不带 `sslID`) | 新建一条记录 | 会造重复记录 | + +★ 副作用:`Upload()` 把 `primaryDomain` 重算成**证书的第一个 SAN**(`cert.DNSNames[0]`)。 +SAN 顺序一变 `primaryDomain` 就漂移(`#11` 从 `usj.cc` 变 `*.usj.cc`) +→ 匹配记录时**必须补 `domains` 兜底**,否则每次续期都新建一条重复记录。 + +#### 落地改动 + +`deployer.ts`: + +- `uploadSsl()` 改用 `ssl/upload` + `sslID` 原地更新,返回 `{ id, replaced }`; + 旧记录的 `description` 带回,新建后回查库补 id +- **新增 `matchSslRecord()`**:两级匹配(`primaryDomain` 优先,`domains` 兜底),多命中取到期最晚 +- `deploy()` 幂等判定加 `!replaced`;`replaced` 为真时**强制重绑**以刷新 `ssl/` 文件: + ```ts + if (cur.enable && cur.sslId === sslId && !replaced) { /* 跳过 */ } + if (replaced && cur.enable && cur.sslId === sslId) { + log(`1Panel:网站 ${key} 指向的证书 #${sslId} 内容刚被更新,强制重绑以刷新 ssl/ 文件`); + } + ``` + +### 9.12 ★★ 本轮又修掉的 5 个静默失败(2026-10-06 深夜) + +这五个的共同特征:**接口返回 200 / 逻辑不报错,但结果没生效**。 +全部只有「真打线上、真去比对产物」才会现形。 + +#### ① `POST /websites/{id}/https` 的字段名是 `websiteSSLId`,不是 `sslId` + +| | | +|---|---| +| 症状 | 换证书时回 `HTTP 200 + code 500「服务错误: record not found」`,**整个 t-t.live 部署被阻断** | +| 迷惑点 | 报错文案是 DB 层(`record not found`),完全指不到参数上 | +| 真因 | `dto/request/website.go` 里 `WebsiteHTTPSOp{ WebsiteSSLID uint \`json:"websiteSSLId"\` }`。发 `sslId` → Go **静默忽略** → 零值 `0` → `websiteSSLRepo.GetFirst(WithByID(0))` → not found | +| 验证 | 对照实验:`sslId=13 → 500` / `websiteSSLId=13 → 200 code=200` | +| 修复 | 改字段名 | + +#### ② `ssl/update` 换不了证书内容 → 见 §9.11 + +#### ③ 多吉云「复用」判据只比域名集合 → 续期静默空转 + +| | | +|---|---| +| 症状 | 明明刚签了新证书,多吉云那边**不复用**,每次都重传;反过来更糟的情况是「第一次之后再不复用新证书」 | +| 真因链 | 多吉云 list 接口**不返回 PEM**,只能比域名集 → 只看域名集就永远认为「已覆盖」;但我们的 `cert.notAfter` 因为 ④ 退化成了「签发时刻 + 90 天」,与对方 `expire` 差 1~2 小时,比对永远为假 | +| 修复 | ① `parsePemInfo` 修好(见 ④);② 判据带上**到期时间**,放 1 天容差:
`return theirs > 0 && theirs >= cert.notAfter - 86_400_000;` | +| 验证 | `多吉云:已有覆盖 usj.cc, www.usj.cc, artalk.usj.cc 的证书 #41963(到期不早于本次),复用` | + +#### ④ `parsePemInfo()` 对「完整链」返回 `{}` + +| | | +|---|---| +| 症状 | `rec.expireAt` 静默退化成调用方兜底值「签发时刻 + 90 天」 | +| 真因 | 旧实现把 PEM 里**各段 base64 拼接**后一次 `atob`。**中间段尾部的 `=` 填充**出现在字符串中段 → `atob` 抛错 → 整函数返回 `{}`。而完整链(叶 + 中间)恰好是部署器最常拿到的形态 | +| 修复 | 只取**第一段**(叶证书)解析:`blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)`,`blocks[0]` 解不出来就 `return {}` | +| 附带 | 新增 `derLen()` / `parseSanFromDer()`:精确定位 SAN 扩展 OID `2.5.29.17` 再读 `[2] dNSName`,替掉原来的字节扫描启发式 | +| 回归 | 自测新增 **[11] 节 8 项断言**,样本是 openssl 现场生成、写死内联 base64 的叶+中间证书(**两段都以 `=` 结尾,正是 bug 现场**),含精确值断言 `notAfter === 1799063386000`、`SAN === ["leaf.test.example","*.leaf.test.example"]`、以及「链解析不混入中间证书主体名」 | + +#### ⑤ `400 authorization must be pending` —— 是竞态,不是逻辑错 + +| | | +|---|---| +| 症状 | DNS-01 挑战通知偶尔回 `400 authorization must be pending`(200181.xyz 上连中两次) | +| 试过没用 | 「先读状态再 POST」挡不住**毫秒级窗口** —— 读的时候还是 pending,POST 到的时候服务端已置位 | +| 修复 | POST 侧做**幂等容错**:只认这一句错误,吞掉后交由 `pollAuthz` 定论 | +| 验证 | 重试成功,`notAfter: 1799056799000` | + +```ts +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(竞态),改由轮询定论`); +} +``` + +### 9.13 本轮最终状态(2026-10-06 收工) + +**1Panel 证书库:5 条 → 3 条,全部在用** + +| id | primaryDomain | domains | CA / 算法 | 到期 | 绑定站点 | +|---|---|---|---|---|---| +| `#13` | `t-t.live` | `*.t-t.live` | LiteSSL ECC | 2027-01-04 | 5 | +| `#11` | `*.usj.cc` | `usj.cc` | LiteSSL ECC | 2027-01-04 | 5 | +| `#10` | `*.200181.xyz` | `200181.xyz` | LiteSSL ECC | 2027-01-04 | 1 | + +删掉的是 `#12`(重复空壳)与 `#2`(2026-10-09 到期的旧证书)。 + +**全网域只读核验**(`POST /renew-check`): + +| 域名 | 探针 | subject | TLS | daysLeft | needRenew | +|---|---|---|---|---|---| +| `t-t.live` | 119.29.215.187(SNI) | `t-t.live` | TLSv1.3 | 90 | false | +| `usj.cc` | 119.29.215.187(SNI) | `*.usj.cc` | TLSv1.3 | 90 | false | +| `200181.xyz` | `ssh.200181.xyz` | — | — | 90 | false | + +**其它** + +- `t-t.live` 5 个站点:PEM 文件 mtime 全前进、端到端 openssl 握手均为 `CN=t-t.live` / `2027-01-04`、`nginx -t` 通过 +- `usj.cc`:LiteSSL ECC,SAN `usj.cc + *.usj.cc`;多吉云 `#41963`(`algo=ECDSA 256`)绑 3 个 CDN 域名; + **公网 CDN 与源站直连握手均已验证**(`usj.cc` / `www.usj.cc` 均回 `CN=*.usj.cc` / 2027-01-04) +- certimate 工作流清查与处置见 §9.14 +- Worker 侧 `crons = ["17 3 * * *", "0 * * * *"]`(证书 cron 已消失),`certIssue` 改 **501**,`renewAll` 归零; + 线上 `admin.js` md5 与本地一致(`60c1452407580413b104e64038ab8b24`) +- 自测 **125/125 通过**(含 §9.12 ④ 的 8 项 PEM 解析回归);`npm run typecheck` 零错误 +- 新增 4 个常驻工具:`renew-one.mjs`(单域签发+部署)、`rollback-dogecloud.mjs`(多吉云应急回退)、 + `txt-inspect.mjs`(TXT 残留盘点/清理)、`selfcheck-acme.mjs`(CA 账户层诊断) + +### 9.14 ★★ certimate 工作流清查(只停「会签发并部署」的) + +「本项目的 deployer 已经接管部署」这件事,只有在**没有第二个写者**时才成立。 +清查 certimate 全部 7 条工作流后发现:**除 t-t.live 外,usj.cc 与 200181.xyz 的两条申请工作流也一直开着**。 + +| id | 名称 | 改前 | 处置 | 说明 | +|---|---|---|---|---| +| `bsmqgpygir8l01s` | 优世界证书申请工作流 | **1** | **→ 0** | litessl 签 `*.usj.cc;usj.cc` **RSA2048** → dogecloud-cdn + 1panel | +| `xhydhxamrkyfpkj` | 200181.xyz证书申请工作流 | **1** | **→ 0** | litessl 签 `*.200181.xyz;200181.xyz` **RSA2048**(cloudflare DNS)→ 1panel | +| `bokgwzrapy30ko3` | 团团证书申请工作流 | 0 | 保持 | t-t.live,上一轮已停 | +| `3u6mx7yuelexnjd` | 团团ssl证书过期预警 | 0 | 保持 | 上一轮已停 | +| `ss7skufa40n48ua` | 优世界ssl证书过期预警 | 1 | **保留** | 纯监控 `usj.cc`,≤30 天发邮件,不写任何东西 | +| `xtq2q2eudcuwtr2` | 优世界artalk评论ssl证书过期预警 | 1 | **保留** | 纯监控 `artalk.usj.cc`,≤30 天发邮件 | +| `6f1b67j7y54qm1x` | 伍比贰ssl证书过期预警 | 1 | **保留** | 监控 `5b2.cn`(**非本项目域名**),≤15 天发邮件 | + +#### 为什么必须停掉那两条 + +它们的 `bizApply` 配置里有两把「迟早会开火」的枪: + +``` +"skipBeforeExpiryDays": 30, // 距到期 >30 天就跳过 → 现在 90 天,暂时不动 +"skipOnLastSucceeded": true // 上一次成功就不重复 +``` + +所以**今天到未来约 60 天它静默无害**;但一旦进入「到期前 30 天」窗口,它会: +自己签一张 **RSA2048** 的 `*.usj.cc;usj.cc` → 推到 dogecloud + 1panel → **把我们刚切好的 ECC 证书覆盖回去**, +并与本项目的续期互相打架。这种「60 天后才爆、且爆得静默」的竞争必须提前拆掉。 + +#### 处置方式 + +沿用上一轮验证过的姿势(PocketBase 必须先停容器,否则 WAL 回写覆盖): + +```bash +docker stop 1Panel-certimate-OKCO +# 合并 WAL +sqlite3 data.db "PRAGMA wal_checkpoint(TRUNCATE);" +# 备份 → 改 enabled → 复核 +cp data.db data.db.bak-$(date +%Y%m%d-%H%M%S) +# update workflow set enabled=0 where id in (...) +docker start 1Panel-certimate-OKCO +``` + +改库脚本带 **名称守卫**(id 与 name 必须同时对上,否则拒绝执行), +避免以后 id 漂移时误停别的工作流。 + +验收:容器重启 25s 后复核,两条仍为 `enabled=0`,其余五条状态不变。 + +> 回滚:`sqlite3 data.db "update workflow set enabled=1 where id='bsmqgpygir8l01s';"` +> (库在 `/1panel/1panel/apps/certimate/certimate/data/data.db`,同目录留了 `.bak-20261006-200609`) + +#### 遗留:`writeapi.usj.cc` 的手工 vhost 不在本项目的部署面内 + +全网核验时发现 `writeapi.usj.cc` 的 `ssl/fullchain.pem` 仍是 **旧证书** +(`CN=usj.cc` / RSA / LiteSSL RSA CA / 到期 2026-12-07,mtime 2026-10-04 21:50)。 + +原因:它是 **1Panel 手工建的 vhost**,nginx 的 `ssl_certificate` 引用**不在 `/www/sites/` 目录树里** +(`grep -rn ssl_certificate sites/writeapi.usj.cc/` 无结果),因此**不走站点管理**, +`/websites/{id}/https` 的绑定操作碰不到它 —— 证书是**文件拷贝**,与证书库记录解耦。 + +功能上没问题(该证书 SAN 含 `*.usj.cc`,能覆盖 `writeapi.usj.cc`),但: +算法仍是 RSA、62 天后到期、且不会随本项目的续期自动更新 → **需要单独纳管**。