diff --git a/blog-admin/package.json b/blog-admin/package.json index dca8619e..65dafa8a 100644 --- a/blog-admin/package.json +++ b/blog-admin/package.json @@ -7,7 +7,7 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", - "selftest:ssl:build": "tsc src/routes/ssl.ts src/lib/role.ts src/lib/certstore.ts src/lib/certvault.ts src/lib/certprobe.ts src/types/node-tls.d.ts --outDir .selftest-ssl --module commonjs --target es2022 --moduleResolution node --strict --types ./node_modules/@cloudflare/workers-types --skipLibCheck --esModuleInterop --resolveJsonModule", + "selftest:ssl:build": "tsc src/routes/ssl.ts src/lib/role.ts src/lib/certstore.ts src/lib/certvault.ts src/lib/certprobe.ts src/lib/acme.ts src/lib/dnsprovider.ts src/lib/deployer.ts src/lib/certissue.ts src/lib/md5.ts src/types/node-tls.d.ts --outDir .selftest-ssl --module commonjs --target es2022 --moduleResolution node --strict --types ./node_modules/@cloudflare/workers-types --skipLibCheck --esModuleInterop --resolveJsonModule", "selftest:ssl": "npm run selftest:ssl:build && node tools/selftest-ssl.mjs", "db:init:local": "wrangler d1 execute artalk-cf --local --file=./schema.sql", "db:init:remote": "wrangler d1 execute artalk-cf --remote --file=./schema.sql", diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index ffadd13e..e5ba9961 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -1977,6 +1977,9 @@ async function viewSSL() { v.innerHTML = head('证书管家', 'CERT KEEPER', '域名 ' + ov.domains.length + ' 个 · 提醒阈值 ' + ov.notify.daysBefore + ' 天', '' + + '' + + '' + + '' + '' + '' + (ov.mailEnabled ? '' : '邮件未配置')) + @@ -2037,6 +2040,7 @@ function sslDomainTable(ov) { '' + (d.deploy.length ? d.deploy.map((t) => '' + esc(t) + '').join(' ') : '—') + '' + '' + ' ' + + ' ' + ' ' + '' + '' + @@ -2284,6 +2288,34 @@ function sslImportModal(domain) { } /** 实测结果弹窗 —— 结构化展示,别再让用户去 openssl */ +function sslSelfCheckModal(r) { + const items = r.items || []; + const bad = items.filter((x) => !x.ok); + const kindLabel = { ca: 'CA', dns: 'DNS', deploy: '部署', target: '域名' }; + const rows = items.map((x) => + '' + + '' + (x.ok ? '通过' : '失败') + '' + + '' + esc(x.name) + '
' + esc(kindLabel[x.kind] || x.kind) + '
' + + '' + esc(x.detail) + + (x.hint ? '
建议:' + esc(x.hint) + '
' : '') + + '' + + '' + ).join(''); + + modal('环境自检', + (bad.length + ? '
' + + '' + bad.length + ' / ' + items.length + ' 项有问题 —— 这些就是「续期那天才会炸」的地方,建议现在修掉。
' + : '
' + + '全部 ' + items.length + ' 项通过 —— 凭据齐备,续期链路可用。
') + + '
' + + '' + + '' + rows + '
项目结果 / 建议
' + + '

自检不会签发证书、不会写 DNS、不会改站点,' + + '只做只读探测(拉 CA 目录 / 读 TXT / 列站点)。所以它可以随时安全地跑。

' + + '
'); +} + function sslProbeModal(host, r) { const dl = (ms) => ms == null ? '—' : new Date(ms + 8 * 3600e3).toISOString().slice(0, 19).replace('T', ' '); if (!r.ok) { @@ -2527,6 +2559,61 @@ document.addEventListener('click', async (e) => { delete el.dataset.busy; return; } + // 环境自检:不签发、不写 DNS,只验证凭据「连得上、认得对」 + if (act === 'ssl-selfcheck') { + el.dataset.busy = '1'; + el.innerHTML = '自检中…'; + try { + const r = await sslApi('/selfcheck', { method: 'POST' }); + sslSelfCheckModal(r); + } catch (ex) { toast('✕ ' + ex.message, true); } + el.innerHTML = '环境自检'; + delete el.dataset.busy; + return; + } + + // 续期检查(只读,秒回):看哪些域名到了该续的天数 + if (act === 'ssl-renew-check') { + el.dataset.busy = '1'; + try { + const r = await sslApi('/renew-check'); + const need = (r.items || []).filter((i) => i.action !== 'ok'); + if (!need.length) toast('✓ 全部域名都还不需要续期(阈值 ' + r.threshold + ' 天)'); + else toast('⚠ ' + need.map((i) => i.domain + '(' + (i.daysLeft == null ? '无记录' : i.daysLeft + ' 天') + ')').join('、'), true); + state.ssl.renewCheck = r; + await viewSSL(); + } catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; } + return; + } + + // ★ 真正签发(慢,1~3 分钟/域名):必须明确告知用户「正在跑,别关页面」 + 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; + } + }); + return; + } + if (act === 'ssl-notify-save') { const emails = $('#sslNotifyEmails').value.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean); const days = Number($('#sslNotifyDays').value); diff --git a/blog-admin/src/index.ts b/blog-admin/src/index.ts index b0e67b8c..981aa86a 100644 --- a/blog-admin/src/index.ts +++ b/blog-admin/src/index.ts @@ -12,6 +12,7 @@ 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(); @@ -232,11 +233,25 @@ export default { // 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源) // 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存) + // 10 4 * * * = 证书续期检查(探针判定,≤30 天才签) 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 new file mode 100644 index 00000000..efc3fa02 --- /dev/null +++ b/blog-admin/src/lib/acme.ts @@ -0,0 +1,599 @@ +/** + * ACME v2 客户端 —— 纯 WebCrypto + fetch,零依赖。 + * + * 为什么自己写而不引 npm 包(acme-client / acme-js 等): + * · Workers 里跑,包的体积直接进冷启动;acme-client 依赖 node:crypto 的 + * 一堆 Node 专属 API,在 Workers 上要么跑不了要么要 shim; + * · 我们要的能力很窄:ECDSA 账户密钥 + DNS-01 + 签发 + 下载链, + * RFC 8555 那几百行核心逻辑自己写反而更可控(也更好排错)。 + * + * ★ 实现要点(每条都踩过或差点踩): + * + * ① **JWS 用 ES256**:WebCrypto 的 `subtle.sign('ECDSA')` 返回的是 + * **原始 r||s(64 字节)**,而 JWS 的 ES256 要的正是这个格式 —— + * 不是 DER。所以**不要**再包一层 DER 编码(很多 Node 实现要转,Workers 不要)。 + * + * ② **JWK thumbprint**:RFC 7638 规定,必须按**字典序**取字段拼 + * `{"crv":...,"kty":...,"x":...,"y":...}`(不能带别的字段、不能有空格), + * 再做 SHA-256 + base64url。`kid` 就是它。拼错一个字,整个符合验签必挂。 + * + * ③ **重放随机数**:每个请求必须带**新的** nonce(服务端给一次用一次)。 + * 我们从 `Replay-Nonce` 响应头拿;拿不到时**不能瞎编**,得去 newNonce + * 端点要一个。这里统一在 `post()` 里用「先用缓存、无则现取」。 + * + * ④ **badNonce 要重试**:网络抖动会让服务端认为 nonce 已用过。ACME 规范 + * 明确要求客户端遇到 `urn:ietf:params:acme:error:badNonce` **重试**。 + * 不重试就会偶发失败 —— 而续期是无人值守的,偶发失败=证书过期。 + * + * ⑤ **EAB(External Account Binding)**:LiteSSL / ZeroSSL 这类商用 CA 要求 + * 注册时用 CA 给的 kid + HMAC key 签一个内层 JWS。内层用 HS256 + * (`subtle.importKey('raw', ..., {name:'HMAC', hash:'SHA-256'})`)。 + * + * ⑥ **DNS-01 的 key authorization**:`token + '.' + thumbprint` 再做 + * SHA-256 的 **base64url**(RFC 8555 §8.4)。注意是 base64**url**, + * 服务端比对的正是这个串,用标准 base64 会一直 pending 到超时。 + */ + +// ==================================================================== 类型 + +export interface AcmeAccount { + /** 账户私钥(JWK 形式长期保存,比 PEM 好管理) */ + jwk: JsonWebKey; + /** 账户 URL(kid),签发时必须带 */ + kid: string; + /** 服务端目录地址,账户与 CA 绑定 */ + directoryUrl: string; +} + +export interface AcmeIssueResult { + /** 证书链 PEM(叶 + 中间) */ + cert: string; + /** 私钥 PEM */ + key: string; + /** 实际签发了哪些域名 */ + domains: string[]; +} + +export interface AcmeLogger { + (msg: string): void; +} + +interface Directory { + newNonce: string; + newAccount: string; + newOrder: string; + /** ACME v2 用 `revokeCert` 之外,下载走订单自身的 certificate 字段 */ + keyChange?: string; + revokeCert?: string; +} + +// ==================================================================== 小工具 + +const enc = new TextEncoder(); + +/** + * `crypto.subtle.exportKey` 的返回类型是 `ArrayBuffer | JsonWebKey`, + * 按 format 字符串字面量收窄不了。这里包一层断言, + * 省得每个调用点都写 `as`(也更清楚:'jwk' 出 JWK,其余出 ArrayBuffer)。 + */ +const exportJwk = (k: CryptoKey): Promise => + crypto.subtle.exportKey('jwk', k) as Promise; +const exportDer = (k: CryptoKey, format: 'pkcs8' | 'spki' | 'raw'): Promise => + crypto.subtle.exportKey(format, k) as Promise; + +function b64u(bytes: ArrayBuffer | Uint8Array): string { + const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let s = ''; + for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function b64uJson(v: unknown): string { + return b64u(enc.encode(JSON.stringify(v))); +} + +/** PEM 换行(64 列),末尾留换行 —— 多数软件(1Panel / nginx)都要求这样 */ +function toPem(der: ArrayBuffer, label: string): string { + const b = new Uint8Array(der); + let s = ''; + for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); + const b64 = btoa(s); + const lines = b64.match(/.{1,64}/g) || []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + +// ==================================================================== ACME 客户端 + +export class AcmeClient { + private dir: Directory | null = null; + private nonce: string | null = null; + private readonly log: AcmeLogger; + + constructor( + private readonly directoryUrl: string, + private readonly account: { jwk: JsonWebKey; kid: string }, + log?: AcmeLogger, + ) { + this.log = log || (() => {}); + } + + // ---------------------------------------------------------- 底层请求 + + private async directory(): Promise { + if (this.dir) return this.dir; + const r = await fetch(this.directoryUrl, { headers: { Accept: 'application/json' } }); + if (!r.ok) { + // ★ 这个报错值得写细一点:2026-10-06 实测 `https://acme.trustasia.com/acme/directory` + // 是 404(正确地址是 `.../acme/v2/directory`,中间少了 `/v2`)。 + // 目录地址写错是这一层最常见的故障,而默认报错只会给一句 HTTP 404, + // 排查时容易误以为是网络/防火墙问题,所以这里把「URL 本身」顶到最前面。 + throw new Error( + `ACME 目录不可达:HTTP ${r.status}(${this.directoryUrl})` + + `—— 请核对 directoryUrl,多数 CA 的目录地址需要带版本段(如 …/acme/v2/directory)`, + ); + } + const d = (await r.json()) as Directory; + if (!d.newNonce || !d.newAccount || !d.newOrder) { + throw new Error(`ACME 目录结构异常(${this.directoryUrl}):缺少 newNonce/newAccount/newOrder`); + } + this.dir = d; + return d; + } + + /** 目录里是否声明「必须绑定外部账户(EAB)」——LiteSSL/ZeroSSL 都是 true */ + async externalAccountRequired(): Promise { + const d = (await this.directory()) as Directory & { meta?: { externalAccountRequired?: boolean } }; + return d.meta?.externalAccountRequired === true; + } + + private async takeNonce(): Promise { + if (this.nonce) { + const n = this.nonce; + this.nonce = null; + return n; + } + const d = await this.directory(); + // HEAD 也能拿(规范允许),GET 更稳 —— 有些中间设备会把 HEAD 的 header 吞掉 + const r = await fetch(d.newNonce, { method: 'GET' }); + const n = r.headers.get('Replay-Nonce'); + if (!n) throw new Error('ACME 服务器没有返回 Replay-Nonce'); + return n; + } + + /** 有 kid → 用 kid;没有(注册阶段)→ 用 jwk */ + 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; + return base; + } + + private async signJws(protectedHeader: Record, payload: unknown): Promise { + const key = await crypto.subtle.importKey( + 'jwk', + this.account.jwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'], + ); + const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`; + 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) }); + } + + /** 发一个 POST;自动带 nonce、自动在 badNonce 时重试 */ + private async post(url: string, payload: unknown, opts: { useJwk?: boolean; retries?: number } = {}): Promise { + const retries = opts.retries ?? 2; + const dir = await this.directory(); + void dir; + + for (let attempt = 0; ; attempt++) { + // 注册时用 jwk(此时还没有 kid),其余用 kid。用临时对象绕开 + const savedKid = this.account.kid; + if (opts.useJwk) this.account.kid = ''; + try { + const nonce = await this.takeNonce(); + 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' }, + body, + }); + const n = r.headers.get('Replay-Nonce'); + if (n) this.nonce = n; // 缓存下一个 nonce,省一次往返 + if (r.ok) return r; + + // 非 2xx:判断是不是 badNonce(可重试),其余直接抛 + const text = await r.text(); + let type = ''; + try { + type = (JSON.parse(text) as { type?: string }).type || ''; + } catch { + /* 不是 JSON 就按原文处理 */ + } + if (type.includes('badNonce') && attempt < retries) { + this.nonce = null; // 强制重新取 + continue; + } + throw new Error(`ACME 请求失败 HTTP ${r.status}:${text.slice(0, 300)}`); + } finally { + if (opts.useJwk) this.account.kid = savedKid; + } + } + } + + /** POST-as-GET(RFC 8555 §6.3):读资源也要用 POST 签名,不能直接 GET */ + private async postAsGet(url: string): Promise { + return this.post(url, ''); // payload 为空字符串(不是 null —— null 是「要服务端删掉字段」) + } + + // ---------------------------------------------------------- 账户 + + /** + * 注册(或找回)账户。 + * @param contact 邮箱,如 ['mailto:me@example.com'];可空 + * @param eab CA 要求的外部账户绑定(LiteSSL/ZeroSSL 必填) + */ + async registerAccount(contact: string[], eab?: { kid: string; hmacKeyB64: string }): Promise { + const d = await this.directory(); + const payload: Record = { + termsOfServiceAgreed: true, + ...(contact.length ? { contact } : {}), + }; + + if (eab) { + // ★ 内层 JWS:protected 只放 alg/kid/url,payload 是账户 JWK 本身 + 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 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 = { + protected: innerProtected, + payload: innerPayload, + signature: b64u(innerSig), + }; + } + + const r = await this.post(d.newAccount, payload, { useJwk: true }); + // ★ 账户已存在时服务端回 200 + Location(不是 201),一样取 Location + const kid = r.headers.get('Location'); + if (!kid) throw new Error('ACME 注册成功但没有返回 Location(拿不到账户 URL)'); + this.account.kid = kid; + return kid; + } + + // ---------------------------------------------------------- 签发 + + /** + * DNS-01 签发。 + * + * @param domains 要签的域名(第一个作为 CN,其余进 SAN) + * @param setTxt 写 TXT 记录:`(name, value) => Promise` + * @param clearTxt 删 TXT 记录(失败不影响签发,只记日志) + * @param opts.waitSeconds 写完后等多久让 DNS 生效(默认 30s,DNSPod 一般 10s 内) + */ + async issueDns01( + domains: string[], + setTxt: (name: string, value: string) => Promise, + clearTxt: (name: string, value: string) => Promise, + opts: { waitSeconds?: number; timeoutMs?: number } = {}, + ): Promise { + if (!domains.length) throw new Error('至少要一个域名'); + const d = await this.directory(); + const thumbprint = await this.jwkThumbprint(); + + // ① 下订单 + const orderRes = await this.post(d.newOrder, { identifiers: domains.map((v) => ({ type: 'dns', value: v })) }); + const order = (await orderRes.json()) as { + status: string; + authorizations: string[]; + finalize: string; + certificate?: string; + }; + const orderUrl = orderRes.headers.get('Location') || ''; + this.log(`订单已创建(${domains.join(', ')}),状态 ${order.status}`); + + // ② 逐个授权:写 DNS → 通知就绪 → 轮询 + const written: { name: string; value: string }[] = []; + try { + for (const authzUrl of order.authorizations) { + const authz = (await (await this.postAsGet(authzUrl)).json()) as { + status: string; + identifier: { value: string }; + challenges: { type: string; url: string; token: string; status: string }[]; + }; + if (authz.status === 'valid') { + this.log(`${authz.identifier.value} 已授权(跳过)`); + continue; + } + const chal = authz.challenges.find((c) => c.type === 'dns-01'); + if (!chal) throw new Error(`${authz.identifier.value} 没有 dns-01 挑战(CA 不支持 DNS 验证?)`); + + // ★ key authorization:token + '.' + thumbprint,再做 SHA-256 并 base64url + const keyAuth = `${chal.token}.${thumbprint}`; + const digest = await crypto.subtle.digest('SHA-256', enc.encode(keyAuth)); + const txtValue = b64u(digest); + const recName = `_acme-challenge.${stripWildcard(authz.identifier.value)}`; + + this.log(`写 TXT:${recName} = ${txtValue.slice(0, 16)}…`); + await setTxt(recName, txtValue); + written.push({ name: recName, value: txtValue }); + + // ③ 等 DNS 传播 —— 不给自己留这个时间,验证会一直 pending 到超时 + 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); + } + + // ⑤ finalize:CSR + const certKeyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + const csrDer = await makeCsr(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(); + + // ★ exportKey('pkcs8') 的 TS 重载返回 ArrayBuffer | JsonWebKey(因为 format 是联合), + // 这里 format 已确定是 pkcs8,用 exportDer 包装断言回 ArrayBuffer。 + const pkcs8 = await exportDer(certKeyPair.privateKey, 'pkcs8'); + const keyPem = toPem(pkcs8, 'PRIVATE KEY'); + + this.log('证书已签发 ✓'); + return { cert: certPem.trim() + '\n', key: keyPem, domains }; + } finally { + // ⑦ 无论成败都清理 TXT —— 留着 `_acme-challenge` 会干扰下次验证 + for (const w of written) { + try { + await clearTxt(w.name, w.value); + } catch (e) { + this.log(`清理 TXT 失败(不影响签发):${e instanceof Error ? e.message : e}`); + } + } + } + } + + 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; + identifier: { value: string }; + challenges: { type: string; error?: { detail?: string } }[]; + }; + if (a.status === 'valid') return; + if (a.status === 'invalid') { + const err = a.challenges.find((c) => c.error)?.error?.detail; + throw new Error(`${a.identifier.value} 验证失败:${err || '未知原因(多半是 TXT 没生效或值不对)'}`); + } + if (Date.now() > deadline) throw new Error(`${a.identifier.value} 验证超时(${timeoutMs / 1000}s)`); + await sleep(3000); + } + } + + private async pollOrder(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const o = (await (await this.postAsGet(url)).json()) as { + status: string; + certificate?: string; + error?: { detail?: string }; + }; + if (o.status === 'valid' && o.certificate) return o.certificate; + if (o.status === 'invalid') throw new Error(`订单失败:${o.error?.detail || '未知原因'}`); + if (Date.now() > deadline) throw new Error(`订单超时(${timeoutMs / 1000}s)`); + await sleep(3000); + } + } + + /** RFC 7638 JWK thumbprint —— 字段必须按字典序、不能多不能少 */ + async jwkThumbprint(): Promise { + const j = this.account.jwk; + const canonical = JSON.stringify({ crv: j.crv, kty: j.kty, x: j.x, y: j.y }); + return b64u(await crypto.subtle.digest('SHA-256', enc.encode(canonical))); + } +} + +// ==================================================================== 辅助 + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function stripWildcard(host: string): string { + return host.startsWith('*.') ? host.slice(2) : host; +} + +/** + * 生成一个全新的 ACME 账户密钥(ECDSA P-256)。 + * + * ★ 用 EC 而不是 RSA:Workers 的 CPU 时间有限,RSA-2048 生成在冷启动时 + * 可能要几百毫秒到 1 秒,EC 只要几毫秒。ACME 两者都接受。 + */ +export async function newAccountKey(): Promise { + const kp = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + return exportJwk(kp.privateKey); +} + +// ==================================================================== CSR(手写 DER) + +/** + * 生成 PKCS#10 CSR(DER)。 + * + * ★ 为什么不引 asn1.js / pkijs:我们只需要一种固定的结构 + * (1 个 CN + N 个 SAN,签名算法 ECDSA-SHA256),硬编码 DER 模板 + * 比引一个几百 KB 的 ASN.1 库划算得多,也少一个供应链面。 + * + * DER 结构(RFC 2986): + * CertificationRequest ::= SEQUENCE { + * certificationRequestInfo SEQUENCE { + * version INTEGER (0), + * subject Name, -- CN=<第一个域名> + * subjectPKInfo SubjectPublicKeyInfo, + * attributes [0] { -- 里面塞 extensionRequest(SAN) + * SEQUENCE { OID 1.2.840.113549.1.9.14, SET { SEQUENCE { SAN 扩展 } } } + * } + * }, + * signatureAlgorithm SEQUENCE { OID ecdsa-with-SHA256 }, + * signature BIT STRING + * } + */ +async function makeCsr(keyPair: CryptoKeyPair, domains: string[]): Promise { + const cn = domains[0]; + const pub = await exportJwk(keyPair.publicKey); + const x = b64uToBytes(pub.x!); + const y = b64uToBytes(pub.y!); + + // ---- SubjectPublicKeyInfo ---- + const spki = seq( + // AlgorithmIdentifier: id-ecPublicKey(1.2.840.10045.2.1) + prime256v1(1.2.840.10045.3.1.7) + seq(oid('1.2.840.10045.2.1'), oid('1.2.840.10045.3.1.7')), + // BIT STRING 里是 0x04 || X || Y(未压缩点) + bitStr(concat(new Uint8Array([0x04]), x, y)), + ); + + // ---- SAN 扩展(2.5.29.17)---- + // + // ★ 这里有个**极容易多套一层 SEQUENCE** 的坑(实测踩过,openssl 报 + // `wrong tag ... Field=object, Type=X509_ATTRIBUTE`)。逐字节对齐 + // `openssl req -new` 的产物后,正确结构是: + // + // attributes [0] { -- a0 2e + // SEQUENCE { -- 30 2c Attribute + // OID 1.2.840.113549.1.9.14 -- 06 09… Extension Request + // SET { -- 31 1f + // SEQUENCE { -- 30 1d Extensions + // SEQUENCE { -- 30 1b SAN 扩展本身 + // OID 2.5.29.17, + // OCTET STRING { SEQUENCE OF GeneralName } + // } + // } + // } + // } + // } + // + // 关键点:`[0]` 里**直接**就是 Attribute 的 SEQUENCE —— 写成 + // `rawTag(0xa0, seq(seq(...)))` 会多一层,OpenSSL 就会拿 + // SAN 的 OID 去当 attribute type 查表,于是一路 `nested asn1 err`。 + const sanNames = domains.map((d) => rawTag(0x82, enc.encode(d))); // [2] dNSName + const sanExt = seq( + oid('2.5.29.17'), + // extnValue 里包的才是真正的 SEQUENCE OF GeneralName + octetStr(seq(...sanNames)), + ); + const extReq = rawTag(0xa0, seq(oid('1.2.840.113549.1.9.14'), setOf(seq(sanExt)))); + + // ---- CertificationRequestInfo ---- + const cri = seq( + int(0), // version + // Name: 相对专有名词 [SET { SEQUENCE { OID commonName, UTF8String } }] + seq(setOf(seq(oid('2.5.4.3'), rawTag(0x0c, enc.encode(cn))))), + spki, + extReq, + ); + + // ---- 签名 ---- + // ★ TS 5.7 起 Uint8Array 是泛型(Uint8Array),而 subtle.sign + // 要的是 BufferSource 里收窄过的 ArrayBufferView。 + // 复制成一份确定 backing 的 buffer 即可消除 SharedArrayBuffer 的可能性。 + const criBytes = new Uint8Array(cri).buffer as ArrayBuffer; + const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, criBytes); + // ★ CSR 的签名要 **DER 编码的 ECDSA-Sig-Value**(SEQUENCE { r, s }), + // 与 JWS 的原始 r||s 不同 —— 这里必须转。 + const r = trimLeadingZeros(new Uint8Array(sig).slice(0, 32)); + const s = trimLeadingZeros(new Uint8Array(sig).slice(32)); + const derSig = seq(intBytes(r), intBytes(s)); + + const csr = seq(cri, seq(oid('1.2.840.10045.4.3.2')), bitStr(derSig)); + return csr.buffer as ArrayBuffer; +} + +// ---- 极简 DER 编码器 ---- + +function concat(...arrs: Uint8Array[]): Uint8Array { + const total = arrs.reduce((n, a) => n + a.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrs) { + out.set(a, off); + off += a.length; + } + return out; +} + +/** 按 DER 长度规则包装:短形式(<128)或长形式 */ +function wrap(tag: number, content: Uint8Array): Uint8Array { + const len = content.length; + let lenBytes: Uint8Array; + if (len < 0x80) lenBytes = new Uint8Array([len]); + else if (len < 0x100) lenBytes = new Uint8Array([0x81, len]); + else if (len < 0x10000) lenBytes = new Uint8Array([0x82, len >> 8, len & 0xff]); + else lenBytes = new Uint8Array([0x83, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff]); + return concat(new Uint8Array([tag]), lenBytes, content); +} + +const seq = (...parts: Uint8Array[]) => wrap(0x30, concat(...parts)); +const setOf = (...parts: Uint8Array[]) => wrap(0x31, concat(...parts)); +const octetStr = (b: Uint8Array) => wrap(0x04, b); +const bitStr = (b: Uint8Array) => wrap(0x03, concat(new Uint8Array([0x00]), b)); // 0 unused bits +const rawTag = (tag: number, b: Uint8Array) => wrap(tag, b); + +/** OID 编码:第一字节 = 40*a+b,其余按 7 位变长 */ +function oid(dotted: string): Uint8Array { + const parts = dotted.split('.').map(Number); + const body: number[] = [40 * parts[0] + parts[1]]; + for (const v of parts.slice(2)) { + const stack: number[] = [v & 0x7f]; + let x = v >> 7; + while (x > 0) { + stack.unshift((x & 0x7f) | 0x80); + x >>= 7; + } + body.push(...stack); + } + return wrap(0x06, new Uint8Array(body)); +} + +/** INTEGER:正数,最高位为 1 时要补 0x00 前缀 */ +function intBytes(bytes: Uint8Array): Uint8Array { + const body = bytes[0] & 0x80 ? concat(new Uint8Array([0x00]), bytes) : bytes; + return wrap(0x02, body); +} + +function int(v: number): Uint8Array { + return intBytes(new Uint8Array([v])); +} + +function trimLeadingZeros(b: Uint8Array): Uint8Array { + let i = 0; + while (i < b.length - 1 && b[i] === 0) i++; + return b.slice(i); +} + +function b64uToBytes(s: string): Uint8Array { + const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/blog-admin/src/lib/certissue.ts b/blog-admin/src/lib/certissue.ts new file mode 100644 index 00000000..bdb4ef89 --- /dev/null +++ b/blog-admin/src/lib/certissue.ts @@ -0,0 +1,344 @@ +/** + * 签发 / 续期编排 —— 证书管家的「执行」半边。 + * + * 一次签发任务的完整链路: + * 读配置 → 建 ACME 客户端(含 EAB)→ 注册/找回账户 → DNS-01 签发 + * → 落库(KV,密文)→ 逐目标部署 → 记日志 + * + * ★ 续期判定:**先探针、再决定签不签**。 + * certimate 的做法是「每天定时无条件跑整个流水线」,靠 CA 侧对已有有效证书 + * 的复用避免浪费。我们改成**显式查剩余天数**再决定 —— 两个原因: + * ① CA 复用有前提(同一账户 + 同一密钥),我们每次换密钥,复用不了, + * 每天跑等于每天真的签一张新证书,白白消耗 Let's Encrypt 的限速额度 + * (同一域名每周 50 张); + * ② 显式判定让日志和 UI 能准确说「为什么今天没签」,而不是一堆无意义的成功记录。 + * + * ★ 为什么 ACME 账户密钥存在 KV 而不是内存/每次新生成: + * Let's Encrypt 对「每个账户每个域名每周 50 张」做限速,但还有一条 + * 「每个 IP 每 3 小时 20 个新账户」的注册限速。每次都注册新账户, + * 一旦某天多跑几次就撞限速。账户要复用。 + */ + +import type { Env } from '../types'; +import { AcmeClient, newAccountKey, type AcmeAccount } from './acme'; +import { appendLog, getAccess, getCert, putCert, loadConfig, type CertRecord, type DomainConfig } from './certstore'; +import { makeDnsProvider } from './dnsprovider'; +import { makeDeployer } from './deployer'; +import { daysLeft, parsePemInfo, probeTls } from './certprobe'; + +const P = 'certkeeper:'; +const ACCOUNT_KEY = P + 'acme-account'; + +/** 续期阈值:剩余天数 ≤ 这个值才动手(Let's Encrypt 有效期 90 天,30 天留足冗余) */ +export const RENEW_BEFORE_DAYS = 30; + +export interface IssueOptions { + /** 强制签发,忽略剩余天数检查 */ + force?: boolean; + /** 只签发不部署(调试用) */ + noDeploy?: boolean; + /** 谁触发的(记日志) */ + by?: string; +} + +export interface IssueOutcome { + domain: string; + ok: boolean; + /** 跳过的原因(ok=true 且 skipped 时有效) */ + skipped?: boolean; + reason: string; + /** 签发后证书的到期时间 */ + notAfter?: number; + daysLeft?: number; + /** 各部署目标的执行结果 */ + deploys?: { target: string; ok: boolean; details: string[] }[]; + /** 执行过程中的步骤(给 UI 展示进度用) */ + steps?: string[]; +} + +// ==================================================================== ACME 账户 + +/** + * 取(或创建)常驻的 ACME 账户。 + * + * ★ 账户是**按 CA 存**的:换了 directoryUrl 就相当于换了个 CA, + * 老 kid 在新 CA 上无效,必须重新注册。这里把 directoryUrl 一起存进记录里比较。 + * + * ★ EAB 必须在**首次注册**时就带上(LiteSSL / ZeroSSL 强制要求), + * 漏了会直接 400 —— 所以调用方要把 eab 传进来,不能等注册完再补。 + */ +export async function getAcmeAccount( + env: Env, + directoryUrl: string, + contact: string[], + eab?: { kid: string; hmacKeyB64: string }, +): Promise { + const raw = await env.RSS_KV.get(ACCOUNT_KEY); + if (raw) { + try { + const saved = JSON.parse(raw) as AcmeAccount; + if (saved.directoryUrl === directoryUrl && saved.jwk && saved.kid) return saved; + } catch { + /* 坏了就重建 */ + } + } + + // 新建账户密钥 → 注册 → 存下来 + const jwk = await newAccountKey(); + const client = new AcmeClient(directoryUrl, { jwk, kid: '' }); + const kid = await client.registerAccount(contact, eab); + const account: AcmeAccount = { jwk, kid, directoryUrl }; + await env.RSS_KV.put(ACCOUNT_KEY, JSON.stringify(account)); + return account; +} + +// ==================================================================== 签发 + +/** + * 给一个域名组签发证书(并部署)。 + * + * 关键约束:`DomainConfig.san` 里的**第一个非泛域名**用作探测主机, + * 但 ACME 订单用**完整 SAN 列表**(含 `*.usj.cc`),这样一张证书同时覆盖 + * 主域名和所有子域名。 + */ +export async function issueDomain(env: Env, d: DomainConfig, opts: IssueOptions = {}): Promise { + const name = d.name; + const by = opts.by || 'system'; + const log = (level: 'info' | 'warn' | 'error', message: string) => + appendLog(env, { at: Date.now(), level, action: 'renew', domain: name, message: `${by}: ${message}` }); + const step: string[] = []; + const note = (m: string) => { + step.push(m); + console.log(`[certkeeper] ${name} ${m}`); + }; + + if (d.disabled) { + return { domain: name, ok: true, skipped: true, reason: '域名已停用' }; + } + + // ---- ① 要不要签?先看线上真实剩余天数 ---- + 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); + const liveLeft = daysLeft(live.notAfter); + if (live.ok && liveLeft !== null && liveLeft > RENEW_BEFORE_DAYS) { + // 线上证书还好好的 —— 顺手把探针拿到的真实信息补进库里(KV 里可能是旧记录) + if (live.notAfter) { + const rec = await getCert(env, name); + if (!rec || Math.abs(rec.expireAt - live.notAfter) > 86400000) { + const full = rec || { cert: '', key: '', expireAt: live.notAfter, updatedAt: Date.now() }; + await putCert(env, name, { + ...full, + expireAt: live.notAfter, + issuer: live.issuer || full.issuer, + san: live.altNames?.length ? live.altNames : full.san, + }); + } + } + return { + domain: name, + ok: true, + skipped: true, + reason: `线上证书还剩 ${liveLeft} 天(阈值 ${RENEW_BEFORE_DAYS} 天),不需要续期`, + notAfter: live.notAfter, + daysLeft: liveLeft, + }; + } + note(`需要续期:线上${liveLeft === null ? '探测不到到期日' : `仅剩 ${liveLeft} 天`}`); + } + + // ---- ② 备齐凭据 ---- + const dnsRec = await getAccess(env, d.dns); + if (!dnsRec) { + const msg = `DNS 凭据「${d.dns}」不存在`; + await log('error', msg); + return { domain: name, ok: false, reason: msg }; + } + // 找一条 acme-eab 凭据作为 CA 账户绑定。约定:配置里没显式指定时, + // 优先用 litessl(三组域名的实际 CA),没有就退回第一条 acme-eab。 + const cfg = await loadConfig(env); + void cfg; + const eabRec = await findEabAccess(env, d); + if (!eabRec) { + const msg = '找不到可用的 ACME CA 凭据(acme-eab 类型)'; + await log('error', msg); + return { domain: name, ok: false, reason: msg }; + } + + const directoryUrl = String(eabRec.directoryUrl || ''); + const eabKid = String(eabRec.eabKid || ''); + const eabHmac = String(eabRec.eabHmacKey || ''); + if (!directoryUrl) { + const msg = `CA 凭据「${String(eabRec.note || '')}」缺少 directoryUrl`; + await log('error', msg); + return { domain: name, ok: false, reason: msg }; + } + + // ---- ③ 注册/找回账户 ---- + let account: AcmeAccount; + try { + const contact = cfg.notify.emails.length ? cfg.notify.emails.map((e) => `mailto:${e}`) : []; + // ★ EAB 必须在首次注册时带上(LiteSSL / ZeroSSL 强制),所以这里一起传 + account = await getAcmeAccount( + env, + directoryUrl, + contact, + eabKid && eabHmac ? { kid: eabKid, hmacKeyB64: eabHmac } : undefined, + ); + note(`ACME 账户就绪(${issuerFromDirectory(directoryUrl)})`); + } catch (e) { + const msg = `ACME 账户注册失败:${err(e)}`; + await log('error', msg); + return { domain: name, ok: false, reason: msg }; + } + + const client = new AcmeClient(directoryUrl, { jwk: account.jwk, kid: account.kid }, note); + + // ---- ④ 签发 ---- + const dns = makeDnsProvider(dnsRec); + // 订单里用完整 SAN(含通配),保证一张证书覆盖主域 + 全部子域 + const orderDomains = d.san.length ? d.san : [name]; + let issued; + try { + issued = await client.issueDns01( + orderDomains, + (n, v) => dns.addTxt(n, v), + (n, v) => dns.delTxt(n, v), + { waitSeconds: 30, timeoutMs: 240_000 }, + ); + note(`签发成功(${orderDomains.join(', ')})`); + } catch (e) { + const msg = `签发失败:${err(e)}`; + await log('error', msg); + return { domain: name, ok: false, reason: msg }; + } + + // ---- ⑤ 落库 ---- + const info = parsePemInfo(issued.cert); + const rec: CertRecord = { + cert: issued.cert, + key: issued.key, + expireAt: info.notAfter || Date.now() + 90 * 86400000, + updatedAt: Date.now(), + issuer: issuerFromDirectory(directoryUrl), + san: info.altNames?.length ? info.altNames : orderDomains, + }; + await putCert(env, name, rec); + const left = daysLeft(rec.expireAt); + await log('info', `签发成功,新证书有效期至 ${new Date(rec.expireAt).toISOString().slice(0, 10)}(${left} 天)`); + + // ---- ⑥ 部署 ---- + // ★ 部署器按**凭据类型**自动构造(makeDeployer 认 type 字段), + // 所以这里只需要把「目标名 → 凭据名」对上。约定: + // dogecloud → 同名凭据;1panel → '1panel-cn'(国内机那台) + const deploys: { target: string; ok: boolean; details: string[] }[] = []; + if (!opts.noDeploy) { + for (const target of d.deploy) { + const credName = target === '1panel' ? '1panel-cn' : target; + const cred = await getAccess(env, credName); + if (!cred) { + deploys.push({ target, ok: false, details: [`找不到凭据「${credName}」`] }); + await log('warn', `部署到 ${target} 跳过:凭据「${credName}」不存在`); + continue; + } + const lines: string[] = []; + try { + const dp = makeDeployer(cred); + const res = await dp.deploy( + { domain: name, cert: rec.cert, key: rec.key }, + { + dogecloudDomains: d.dogecloud_domains, + onePanelSites: d.one_panel_sites, + log: (m) => { + lines.push(m); + note(m); + }, + }, + ); + deploys.push({ target, ok: true, details: res.details }); + await log('info', `部署到 ${target}:${res.details.join(';') || '完成'}`); + } catch (e) { + const msg = `部署到 ${target} 失败:${err(e)}`; + deploys.push({ target, ok: false, details: [...lines, msg] }); + await log('error', msg); + } + } + } + + const allOk = deploys.every((x) => x.ok); + return { + domain: name, + ok: allOk, + reason: allOk ? `签发并部署完成(${left} 天)` : '证书已签发,但部分部署失败(见日志)', + notAfter: rec.expireAt, + daysLeft: left ?? undefined, + deploys, + steps: step, + }; +} + +// ==================================================================== 批量 + +/** 续期检查(cron 调):逐个域名判断并签发 */ +export async function renewAll(env: Env, opts: IssueOptions = {}): Promise { + let cfg; + try { + cfg = await loadConfig(env); + } catch (e) { + await appendLog(env, { + at: Date.now(), + level: 'error', + action: 'renew', + message: `读配置失败,本次续期跳过:${err(e)}`, + }); + return []; + } + + const out: IssueOutcome[] = []; + for (const d of cfg.domains) { + if (d.disabled) continue; + try { + out.push(await issueDomain(env, d, opts)); + } catch (e) { + const msg = `续期 ${d.name} 时异常:${err(e)}`; + await appendLog(env, { at: Date.now(), level: 'error', action: 'renew', domain: d.name, message: msg }); + out.push({ domain: d.name, ok: false, reason: msg }); + } + } + return out; +} + +// ==================================================================== 辅助 + +async function findEabAccess(env: Env, d: DomainConfig): Promise | null> { + void env; + void d; + // 约定:优先 litessl;它在三组域名上都在用,且是当前实际 CA。 + const preferred = 'litessl'; + const rec = await getAccess(env, preferred); + if (rec && rec.type === 'acme-eab') return rec as unknown as Record; + // 退路:扫一遍所有凭据找第一条 acme-eab + const { listAccess } = await import('./certstore'); + const list = await listAccess(env); + for (const item of list) { + if (item.type === 'acme-eab') { + const full = await getAccess(env, item.name); + if (full) return full as unknown as Record; + } + } + return null; +} + +function issuerFromDirectory(url: string): string { + if (url.includes('trustasia')) return 'LiteSSL (TrustAsia)'; + if (url.includes('letsencrypt')) return "Let's Encrypt"; + if (url.includes('zerossl')) return 'ZeroSSL'; + if (url.includes('google')) return 'Google Trust Services'; + if (url.includes('ssl.com')) return 'SSL.com'; + if (url.includes('buypass')) return 'Buypass'; + return url.replace(/^https?:\/\//, '').split('/')[0]; +} + +function err(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/blog-admin/src/lib/deployer.ts b/blog-admin/src/lib/deployer.ts new file mode 100644 index 00000000..0401391e --- /dev/null +++ b/blog-admin/src/lib/deployer.ts @@ -0,0 +1,610 @@ +/** + * 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。 + * + * 目前两个目标(对应 `certstore.ts` 里的 `DEPLOY_TARGETS`): + * · dogecloud 多吉云 CDN —— 上传证书 + 绑到指定加速域名 + * · 1panel 1Panel 面板 —— 上传证书 + 绑到指定网站(走 openresty) + * + * ★ 两家的 API 风格完全相反,各自的坑单独记在下面各自的类里。 + * + * ★ 为什么部署要「幂等」(重复调用不出错): + * 续期失败一次就可能连着重试;更要紧的是——**每天都会跑一遍**, + * 如果每次都无脑新建证书,多吉云那边的证书列表会膨胀成几百条, + * 1Panel 那边会不断覆盖同名 SSL。所以这里的每个动作都先查后写。 + */ + +import type { AccessRecord } from './certstore'; + +// ==================================================================== 类型 + +export interface DeployCert { + /** 主域名(1Panel 用来给 SSL 起名字,多吉云用来做备注) */ + domain: string; + /** 证书链 PEM(叶 + 中间,多吉云要求含完整链) */ + cert: string; + /** 私钥 PEM */ + key: string; +} + +export interface DeployResult { + /** 目标标签,日志里用 */ + target: string; + /** 这次实际做了什么(用于日志/UI 展示),如「绑定 usj.cc」 */ + details: string[]; +} + +export interface Deployer { + readonly kind: string; + /** 把证书推到这个目标的所有配置对象上 */ + deploy(cert: DeployCert, opts: DeployOptions): Promise; +} + +export interface DeployOptions { + /** 多吉云:要绑的加速域名列表(空则只上传不绑定) */ + dogecloudDomains?: string[]; + /** 1Panel:要绑的网站(域名或 id)列表(空则只上传不绑定) */ + onePanelSites?: string[]; + /** 追加日志 */ + log?: (msg: string) => void; +} + +// ==================================================================== 工具 + +const enc = (s: string) => new TextEncoder().encode(s); + +// ==================================================================== 多吉云 CDN + +/** hex 输出(多吉云签名用) */ +function bufToHex(buf: ArrayBuffer | Uint8Array): string { + const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + return [...b].map((x) => x.toString(16).padStart(2, '0')).join(''); +} + +/** + * 多吉云(api.dogecloud.com)。 + * + * ★ 签名方式(老派但有性格): + * stringToSign = + "\n" + + * signature = HMAC-SHA1(secretKey, stringToSign) 的 **hex** + * Authorization: `TOKEN :` + * 注意:**不含时间戳**(所以要靠 HTTPS 防重放);HMAC 用的是 **SHA1** 不是 SHA256; + * 输出是 **hex** 不是 base64。这三点任一搞错都只会得到 `401 签名错误`。 + * + * ★ body 必须**原样**参与签名:先序列化成字符串再一起发出去, + * 不能签名 JSON.stringify(a) 却发送 JSON.stringify(b)。 + * 这里统一「先定 body 字符串 → 签名 → 发送同一个字符串」。 + * + * ★ 端点与参数(2026-10-06 用真凭据逐个实测确认,别照抄网上的旧文档): + * POST /cdn/domain/list.json {} → { domains: [{id,name,cname,…}] } + * POST /cdn/cert/list.json {} → { certs: [{id,note,name,domains,…}] } + * POST /cdn/cert/upload.json { note, cert, private } → { id } + * POST /cdn/cert/bind.json { id, domain } → {} + * POST /cdn/cert/delete.json { id } → {} + * 几个容易写错的地方: + * · 列域名是 `/cdn/domain/**list**.json`;`/cdn/domain.json` 会回 + * `400 domain 格式错误`(它其实是「查单个域名」的接口,要传 domain)。 + * · 上传的私钥字段叫 **`private`**(不是 pri/key/privateKey —— 那三个都会回 + * `400 私钥格式错误`)。 + * · 绑定的证书 id 字段是 **`id`**(官方文档如此)。★ 已实测一锤定音: + * 用假 id 999999 试 `{cert_id,…}` 回「域名不存在」(参数被无视), + * 试 `{id,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。 + * + * ★ 幂等策略:上传前先列 cert 列表,若已存在「同一组域名 + 内容相同」的证书 + * 就直接复用它的 id,不再上传。多吉云上传限速约 300 次/日, + * 每天续期检查跑 3 个域名,不做复用虽然也够,但证书列表会越堆越长。 + */ +export class DogeCloudDeployer implements Deployer { + readonly kind = 'dogecloud'; + private static readonly HOST = 'https://api.dogecloud.com'; + + constructor( + private readonly accessKey: string, + private readonly secretKey: string, + ) {} + + private async call(path: string, body: Record | null): Promise { + // ★ body 字符串只算一次,签名和发送用同一个 + const bodyStr = body === null ? '' : JSON.stringify(body); + const stringToSign = `${path}\n${bodyStr}`; + const key = await crypto.subtle.importKey('raw', enc(this.secretKey), { name: 'HMAC', hash: 'SHA-1' }, false, [ + 'sign', + ]); + const sig = bufToHex(await crypto.subtle.sign('HMAC', key, enc(stringToSign))); + + const r = await fetch(DogeCloudDeployer.HOST + path, { + method: 'POST', + headers: { + Authorization: `TOKEN ${this.accessKey}:${sig}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: bodyStr || undefined, + }); + const text = await r.text(); + let d: { code?: number; msg?: string; data?: T }; + try { + d = JSON.parse(text); + } catch { + throw new Error(`多吉云返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`); + } + // code === 200 是成功;0 也有接口用(历史遗留),一并认 + if (d.code !== 200 && d.code !== 0) { + throw new Error(`多吉云 ${path} 失败:code=${d.code} ${d.msg || ''}`); + } + return d.data as T; + } + + async deploy(cert: DeployCert, opts: DeployOptions): Promise { + const log = opts.log || (() => {}); + const details: string[] = []; + + // ① 先看有没有可复用的证书(同一组域名)—— 避免每天续期都堆一张新的 + const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log); + details.push(`证书 #${certId}`); + + // ② 逐个域名绑定(★ 字段名是 cert_id,下划线) + const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean); + if (!domains.length) { + log('多吉云:没有配置要绑定的域名,只上传不绑定'); + return { target: 'dogecloud', details }; + } + for (const domain of domains) { + log(`多吉云:绑定 ${domain}…`); + await this.call('/cdn/cert/bind.json', { id: certId, domain }); + details.push(`绑定 ${domain}`); + } + return { target: 'dogecloud', details }; + } + + /** + * 上传证书;如果已经有「覆盖同一组域名」的证书,直接复用它的 id。 + * + * ★ 复用判据只看**域名集合**,不比对证书内容: + * 多吉云的 list 接口不返回 PEM 正文,比对不了内容;而我们的用途是 + * 「让这些域名用上新证书」,同一组域名本来就该共用同一张证书。 + */ + 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), + ); + + try { + const list = await this.call<{ certs?: { id: number; domains?: { name: string }[] }[] }>( + '/cdn/cert/list.json', + {}, + ); + const hit = (list?.certs || []).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 (hit?.id) { + log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id},复用`); + return hit.id; + } + } catch (e) { + // 列举失败不阻断部署 —— 大不了多传一张,比整个部署失败好 + log(`多吉云:列举已有证书失败(继续上传新的):${e instanceof Error ? e.message : e}`); + } + + log('多吉云:上传证书…'); + const up = await this.call<{ id?: number | string }>('/cdn/cert/upload.json', { + note: `${cert.domain} (${new Date().toISOString().slice(0, 10)})`, + cert: cert.cert, + // ★ 私钥字段名是 `private` —— 实测 pri/key/privateKey 都会回「私钥格式错误」 + private: cert.key, + }); + const id = up?.id; + if (id === undefined || id === null || id === '') { + throw new Error('多吉云上传成功但没返回证书 id(接口可能改了)'); + } + return Number(id); + } +} + +// ==================================================================== 1Panel + +/** + * 1Panel(自建面板)。 + * + * ★ 签名:`1Panel-Token = md5("1panel" + apiKey + timestamp)`(hex), + * 同时带 `1Panel-Timestamp`(unix 秒)。**没有别的材料**, + * 与多吉云那种「body 进签名」完全无关 —— 它就是防重放 + 持有密钥即通过。 + * + * ★ 版本:国内机(119.29.215.187:3721)**必须用 v2**。 + * 2026-10-06 逐条实测的结论(网上和早期笔记里的说法都不准,以实测为准): + * · `/api/v2/dashboard/base/os` → `code:200`,真实数据 ✓ + * · `/api/v1/dashboard/base/os` → **HTTP 200 但正文是 HTML 提示页** + * (`Access Temporarily Unavailable`)—— 看起来像限流,其实是 v1 已停用, + * 面板把「路径不对」统一渲染成了那个页面。这一点极易误判成「被限流了」。 + * + * ★ v2 的请求体要求和 v1 不同,实测踩过的点: + * · `POST /websites/search` 的 `orderBy` / `order` 是 **required** + * (漏了会回 `400 参数错误: Key: 'WebsiteSearch.OrderBy' … required`)。 + * → 固定传 `{orderBy:'created_at', order:'descending'}`。 + * · `/websites/list`、`GET /websites` 在 v2 下都是 404,别用。 + * · `GET /websites/:id` 是 404(不是 `GET /websites?id=`)。 + * · 站点里的 `ssl` 字段在列表里是空的 —— 要拿 https 配置得单独 + * `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) + * + * ★ 网站匹配:域名优先(人配的是域名,id 会变),拿不到再当 id 用。 + */ + +/** 1Panel 的搜索分页包装 */ +interface PageResult { + items?: T[]; + total?: number; +} + +/** 1Panel v2 的网站对象(只列我们关心的字段) */ +interface WebSite { + id: number; + primaryDomain?: string; + /** 别名,多个用逗号分隔 */ + alias?: string; + type?: string; +} + +export class OnePanelDeployer implements Deployer { + readonly kind = '1panel'; + + constructor( + private readonly serverUrl: string, + private readonly apiKey: string, + // ★ 默认 v2 —— 实测国内机只有 v2 能用(v1 会返回一个假的「限流」HTML 页) + private readonly apiVersion: 'v1' | 'v2' = 'v2', + ) {} + + private get base(): string { + return `${this.serverUrl.replace(/\/+$/, '')}/api/${this.apiVersion}`; + } + + private async call(path: string, method: 'GET' | 'POST', body?: unknown): Promise { + const timestamp = String(Math.floor(Date.now() / 1000)); + const md5 = await md5Hex(`1panel${this.apiKey}${timestamp}`); + const r = await fetch(this.base + path, { + method, + headers: { + '1Panel-Token': md5, + '1Panel-Timestamp': timestamp, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await r.text(); + let d: { code?: number; message?: string; data?: T }; + try { + d = JSON.parse(text); + } catch { + // ★ 2026-10-06 实测:1Panel 在「不方便直接回错」时会**返回 HTTP 200 + // 但内容是 HTML**。两种成因,处置完全不同,所以必须分开报: + // ① 开了「安全登录」→ 面板挪到随机入口路径,根路径只回提示页 + // (正文含 `secure login access` / `1pctl user-info`) + // ② 短时间调用过密被限流 → 正文标题 `Access Temporarily Unavailable` + // 只按「非 JSON 就抛错」处理的话,两种都会被抓成一句看不懂的 + // 「返回非 JSON」,排查成本极高。 + if (/secure login access|1pctl user-info/i.test(text)) { + throw new Error( + `1Panel 启用了「安全登录」,面板不在根路径下(serverUrl 少了入口路径)。` + + `SSH 上机执行 \`1pctl user-info\` 拿到入口,再把 serverUrl 改成 ` + + `http://:/<入口路径>`, + ); + } + if (/Access Temporarily Unavailable| { + try { + await this.call('/dashboard/base/os', 'GET'); + return { ok: true }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { + ok: false, + error: msg, + hint: msg.includes('Access Temporarily Unavailable') + ? '1Panel 启用了「安全登录」:面板被挪到了随机入口路径下,根路径只回提示页。' + + '需要 SSH 上机执行 `1pctl user-info` 拿到入口,再把 serverUrl 改成 ' + + '`http://:/<入口路径>`' + : msg.includes('401') + ? 'API Key 不对(1Panel → 设置 → API 接口 里重新生成)' + : undefined, + }; + } + } + + /** 域名 or 数字 id → 网站对象 */ + async findWebsite(key: string): Promise<{ id: number; primaryDomain?: string; alias?: string }> { + // ★ v2 下 `/websites/:id` 是 404,所以「按 id 找」也得走 search: + // 拉一页(orderBy/order 必填)再在前端按 id 过滤。 + // 顺带这一步就拿到了全量网站,后面按域名找也不用再请求一次。 + const all = await this.listWebsites(); + const key_l = key.toLowerCase(); + + const match = (w: WebSite) => { + if (String(w.id) === key) return true; + const names = [w.primaryDomain || '', ...(w.alias || '').split(',')].map((s) => s.trim().toLowerCase()); + return names.includes(key_l); + }; + + const hit = all.find(match); + if (hit) return hit; + throw new Error( + `1Panel 里找不到网站「${key}」(可用主域名或网站别名匹配;目前面板上有 ${all.length} 个网站)`, + ); + } + + /** 拉全量网站列表(v2 的 search 按 name 过滤不可靠,统一拉回来自己筛) */ + private async listWebsites(): Promise { + const page = await this.call>('/websites/search', 'POST', { + page: 1, + pageSize: 200, + // ★ 这两个字段 v2 是 required,漏了直接 400 + orderBy: 'created_at', + order: 'descending', + }); + return page?.items || []; + } + + /** 上传证书;同名同内容的已有 SSL 直接复用,避免面板里堆一堆 */ + private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise { + // ① 查同名(v2 的 ssl/search 同样需要 orderBy/order) + 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); + + 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; + } + + log('1Panel:上传新证书…'); + const up = await this.call<{ id?: number } | number>('/websites/ssl/upload', 'POST', { + 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; + } + + async deploy(cert: DeployCert, opts: DeployOptions): Promise { + const log = opts.log || (() => {}); + const details: string[] = []; + + const sslId = await this.uploadSsl(cert, log); + details.push(`证书 SSL #${sslId}`); + + const sites = (opts.onePanelSites || []).map((s) => s.trim()).filter(Boolean); + if (!sites.length) { + log('1Panel:没有配置要绑定的网站,只上传不绑定'); + return { target: '1panel', details }; + } + + for (const key of sites) { + const site = await this.findWebsite(key); + // ★ 先读现状 —— 幂等的关键。已经在用同一张证书就什么都别做。 + // + // ★★ 字段名是 **`SSL`(全大写)**,不是 `ssl`。写成小写会让 + // `cur.ssl?.id === sslId` 永远为 false → 每次续期都重绑一遍。 + // 危害不止是多余请求:重绑会 brief 地重载该站点的 nginx 配置。 + // + // ★ 这个 GET 的响应里**带明文私钥**(`data.SSL.privateKey`)—— + // 绝不能把它写进日志、日志记录或 HTTP 响应。这里只取需要的几个 + // 标量字段,然后让整个对象尽快离开作用域。 + const resp = await this.call<{ + enable?: boolean; + SSL?: { id?: number; primaryDomain?: string }; + httpConfig?: string; + SSLProtocol?: string[]; + algorithm?: string; + hsts?: boolean; + }>(`/websites/${site.id}/https`, 'GET'); + + const cur = { + enable: resp?.enable, + sslId: resp?.SSL?.id, + httpConfig: resp?.httpConfig, + SSLProtocol: resp?.SSLProtocol, + algorithm: resp?.algorithm, + hsts: resp?.hsts, + }; + + if (cur.enable && cur.sslId === sslId) { + log(`1Panel:网站 ${key} 已经在用这张证书,跳过`); + details.push(`跳过 ${key}(已生效)`); + continue; + } + + // ★ 保留原有配置:HTTP→HTTPS 跳转、协议版本、算法、HSTS —— 只换证书 + const body: Record = { + websiteId: site.id, + type: 'existed', + sslId, + enable: true, + httpConfig: cur.httpConfig || 'HTTPToHTTPS', + SSLProtocol: cur.SSLProtocol?.length ? cur.SSLProtocol : ['TLSv1.2', 'TLSv1.3'], + algorithm: cur.algorithm || 'RSA', + hsts: cur.hsts ?? false, + }; + log(`1Panel:给网站 ${key}(#${site.id})绑定证书…`); + await this.call(`/websites/${site.id}/https`, 'POST', body); + details.push(`绑定 ${key}`); + } + + return { target: '1panel', details }; + } + + /** + * 读某个网站当前的 SSL 绑定情况(只回标量,**绝不回私钥**)。 + * 给「环境自检」用 —— 让管理员能在续期之前就看出「站点绑的是不是我们要的那张」。 + */ + async inspectSite( + key: string, + ): Promise<{ id: number; primaryDomain: string; enable: boolean; sslId?: number; certCN?: string }> { + const site = await this.findWebsite(key); + const resp = await this.call<{ enable?: boolean; SSL?: { id?: number; primaryDomain?: string } }>( + `/websites/${site.id}/https`, + 'GET', + ); + return { + id: site.id, + primaryDomain: site.primaryDomain || '', + enable: !!resp?.enable, + sslId: resp?.SSL?.id, + certCN: resp?.SSL?.primaryDomain, + }; + } +} + +// ==================================================================== 工厂 + +/** + * 按凭据记录造部署器。 + * ★ 只认 dogecloud / 1panel;其余抛错而非静默 —— 理由同 makeDnsProvider。 + */ +export function makeDeployer(rec: AccessRecord): Deployer { + const t = String(rec.type || ''); + if (t === 'dogecloud') { + const ak = String(rec.accessKey || ''); + const sk = String(rec.secretKey || ''); + if (!ak || !sk) throw new Error('多吉云凭据缺少 accessKey / secretKey'); + return new DogeCloudDeployer(ak, sk); + } + if (t === '1panel') { + const url = String(rec.serverUrl || ''); + const key = String(rec.apiKey || ''); + if (!url || !key) throw new Error('1Panel 凭据缺少 serverUrl / apiKey'); + const ver = String(rec.apiVersion || 'v2') === 'v1' ? 'v1' : 'v2'; + return new OnePanelDeployer(url, key, ver); + } + throw new Error(`部署目标不支持凭据类型「${t}」(目前只支持 dogecloud / 1panel)`); +} + +// ==================================================================== md5 + +/** + * 1Panel 要的 md5(hex)。 + * + * ★ 用 `crypto.subtle` 没有 MD5(它是过时算法,WebCrypto 故意不提供), + * 所以自己写一份。这里只需要处理 ASCII("1panel" + apiKey + 时间戳), + * 但为了将来可能复用它算别的,还是按 UTF-8 字节做了正确处理。 + * 实现照 RFC 1321;输出小写 hex。 + */ +export function md5Hex(input: string): Promise { + return Promise.resolve(md5(enc(input))); +} + +function md5(bytes: Uint8Array): string { + const S = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, + 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, + 10, 15, 21, + ]; + const K = new Uint32Array(64); + for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0; + + // 补位:0x80 + 0x00... 到 56 mod 64,再附 64 位长度(小端) + const len = bytes.length; + const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6); + withPad.set(bytes); + withPad[len] = 0x80; + const bitLen = len * 8; + // 低 32 位 + 高 32 位(JS 里用除法拆,避免 >> 32 的坑) + const lo = bitLen >>> 0; + const hi = Math.floor(bitLen / 4294967296) >>> 0; + const dv = new DataView(withPad.buffer); + dv.setUint32(withPad.length - 8, lo, true); + dv.setUint32(withPad.length - 4, hi, true); + + let a0 = 0x67452301; + let b0 = 0xefcdab89; + let c0 = 0x98badcfe; + let d0 = 0x10325476; + + const rotl = (x: number, c: number) => ((x << c) | (x >>> (32 - c))) >>> 0; + + for (let off = 0; off < withPad.length; off += 64) { + const M = new Uint32Array(16); + for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true); + + let A = a0; + let B = b0; + let C = c0; + let D = d0; + + for (let i = 0; i < 64; i++) { + let F: number; + let g: number; + if (i < 16) { + F = (B & C) | (~B & D); + g = i; + } else if (i < 32) { + F = (D & B) | (~D & C); + g = (5 * i + 1) % 16; + } else if (i < 48) { + F = B ^ C ^ D; + g = (3 * i + 5) % 16; + } else { + F = C ^ (B | ~D); + g = (7 * i) % 16; + } + F = (F + A + K[i] + M[g]) >>> 0; + A = D; + D = C; + C = B; + B = (B + rotl(F, S[i])) >>> 0; + } + + a0 = (a0 + A) >>> 0; + b0 = (b0 + B) >>> 0; + c0 = (c0 + C) >>> 0; + d0 = (d0 + D) >>> 0; + } + + return [a0, b0, c0, d0].map((x) => { + // 每个字按小端输出 + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, x, true); + return [...b].map((v) => v.toString(16).padStart(2, '0')).join(''); + }).join(''); +} diff --git a/blog-admin/src/lib/dnsprovider.ts b/blog-admin/src/lib/dnsprovider.ts new file mode 100644 index 00000000..08a49c9c --- /dev/null +++ b/blog-admin/src/lib/dnsprovider.ts @@ -0,0 +1,329 @@ +/** + * DNS 提供商适配层 —— DNS-01 验证时增删 TXT 记录。 + * + * 目前支持两家(覆盖三组域名的实际情况): + * · tencentcloud → DNSPod(usj.cc / t-t.live 的 DNS 都在腾讯云) + * · cloudflare → Cloudflare DNS(200181.xyz) + * + * ★ 两家的签名方式差异很大,这里各自封装成一个 `DnsProvider` 接口: + * addTxt(name, value) / delTxt(name, value) / listTxt(name) + * + * ★ 腾讯云走的是它统一网关的 TC3-HMAC-SHA256 签名(v3),不是 DNSPod 老版 + * 的 secretId + 明文签名。参数全在 JSON body 里,签名串要覆盖 + * `POST\n/\n\ncontent-type:...\nhost:...\n\ncontent-type;host\n`。 + */ + +import type { AccessRecord } from './certstore'; + +export interface DnsProvider { + /** 域名类型标签,报错时带上 */ + readonly kind: string; + /** 写一条 TXT */ + addTxt(fqdn: string, value: string): Promise; + /** 删一条 TXT(值要匹配,避免删掉同名的其它记录) */ + delTxt(fqdn: string, value: string): Promise; + /** 列出某个名字下现有的 TXT 值(用于查重/清理残留) */ + listTxt(fqdn: string): Promise; +} + +// ==================================================================== 工具 + +/** + * 把 FQDN 拆成「记录名 + 根域名」。 + * + * ★ 为什么不能简单地按「最后两段」切:usj.cc 是两段,但 t-t.live 也是两段, + * 而如果以后接入 xxx.com.cn 这种,最后两段就是错的。这里用**已知根域名列表** + * 反查:从最长后缀开始匹配,命中即止。 + */ +export function splitHost(fqdn: string, roots: string[]): { sub: string; root: string } { + const h = fqdn.replace(/\.$/, '').toLowerCase(); + const sorted = [...roots].map((r) => r.toLowerCase()).sort((a, b) => b.length - a.length); + for (const r of sorted) { + if (h === r) return { sub: '@', root: r }; + if (h.endsWith('.' + r)) return { sub: h.slice(0, -(r.length + 1)), root: r }; + } + // 没匹配上就用最后两段兜底(多数情况也对) + const parts = h.split('.'); + if (parts.length <= 2) return { sub: '@', root: h }; + return { sub: parts.slice(0, -2).join('.'), root: parts.slice(-2).join('.') }; +} + +// ==================================================================== 腾讯云 DNSPod + +const TC_HOST = 'dnspod.tencentcloudapi.com'; +const TC_SERVICE = 'dnspod'; +const TC_VERSION = '2021-03-23'; + +/** 腾讯云 TC3-HMAC-SHA256 签名(https://cloud.tencent.com/document/api/1427/56174) */ +async function tc3Sign( + secretId: string, + secretKey: string, + payload: string, + action: string, +): Promise> { + const timestamp = Math.floor(Date.now() / 1000); + const date = new Date(timestamp * 1000).toISOString().slice(0, 10); + + // ① 规范请求串 + const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${TC_HOST}\n`; + const signedHeaders = 'content-type;host'; + const hashedPayload = await sha256Hex(payload); + const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, hashedPayload].join('\n'); + + // ② 待签字符串 + const credentialScope = `${date}/${TC_SERVICE}/tc3_request`; + const hashedCanonical = await sha256Hex(canonicalRequest); + const stringToSign = ['TC3-HMAC-SHA256', String(timestamp), credentialScope, hashedCanonical].join('\n'); + + // ③ 逐层派生签名密钥 + const kDate = await hmacSha256(enc(`TC3${secretKey}`), date); + const kService = await hmacSha256(kDate, TC_SERVICE); + const kSigning = await hmacSha256(kService, 'tc3_request'); + const signature = bufToHex(await hmacSha256(kSigning, stringToSign)); + + const authorization = + `TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}`; + + return { + Authorization: authorization, + 'Content-Type': 'application/json; charset=utf-8', + Host: TC_HOST, + 'X-TC-Action': action, + 'X-TC-Version': TC_VERSION, + 'X-TC-Timestamp': String(timestamp), + }; +} + +export class TencentDns implements DnsProvider { + readonly kind = 'tencentcloud'; + private roots: string[] = []; + + constructor( + private readonly secretId: string, + private readonly secretKey: string, + ) {} + + private async call(action: string, payload: Record): Promise { + const body = JSON.stringify(payload); + const headers = await tc3Sign(this.secretId, this.secretKey, body, action); + const r = await fetch(`https://${TC_HOST}/`, { method: 'POST', headers, body }); + const text = await r.text(); + let d: { Response?: { Error?: { Code?: string; Message?: string }; [k: string]: unknown } }; + try { + d = JSON.parse(text); + } catch { + throw new Error(`DNSPod 返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`); + } + const err = d.Response?.Error; + if (err) throw new Error(`DNSPod ${action} 失败:${err.Code || ''} ${err.Message || ''}`); + return d.Response as T; + } + + /** 根域名列表(缓存在实例上)—— 需要它才能把 fqdn 拆成 sub + root */ + private async loadRoots(): Promise { + if (this.roots.length) return this.roots; + const res = await this.call<{ DomainList?: { Name: string }[] }>('DescribeDomainList', {}); + this.roots = (res.DomainList || []).map((d) => d.Name); + return this.roots; + } + + async addTxt(fqdn: string, value: string): Promise { + const roots = await this.loadRoots(); + const { sub, root } = splitHost(fqdn, roots); + await this.call('CreateRecord', { + Domain: root, + SubDomain: sub === '@' ? '@' : sub, + RecordType: 'TXT', + RecordLine: '默认', + Value: value, + TTL: 600, + }); + } + + async delTxt(fqdn: string, value: string): Promise { + const roots = await this.loadRoots(); + const { sub, root } = splitHost(fqdn, roots); + // 先查同名同值的记录 id,再逐条删 —— 直接删整个名字会误伤别的平台写的 + const res = await this.call<{ RecordList?: { RecordId: number; Value: string }[] }>('DescribeRecordList', { + Domain: root, + Subdomain: sub === '@' ? '@' : sub, + RecordType: 'TXT', + }); + for (const rec of res.RecordList || []) { + // DNSPod 会把值里的 `"` 转义保留,比较前统一去引号 + if (rec.Value.replace(/^"|"$/g, '') === value) { + await this.call('DeleteRecord', { Domain: root, RecordId: rec.RecordId }); + } + } + } + + async listTxt(fqdn: string): Promise { + const roots = await this.loadRoots(); + const { sub, root } = splitHost(fqdn, roots); + const res = await this.call<{ RecordList?: { Value: string }[] }>('DescribeRecordList', { + Domain: root, + Subdomain: sub === '@' ? '@' : sub, + RecordType: 'TXT', + }); + return (res.RecordList || []).map((r) => r.Value.replace(/^"|"$/g, '')); + } +} + +// ==================================================================== Cloudflare + +export class CloudflareDns implements DnsProvider { + readonly kind = 'cloudflare'; + + constructor( + private readonly apiToken: string, + /** + * 可选的固定 Zone ID。 + * + * ★ 为什么需要这个(2026-10-06 实测踩到):Cloudflare 的 API Token 可以 + * 被限制成**「只能操作某几个 zone」**,这种 token 调 `GET /zones` + * (不带 name)会返回 **200 + 空数组**,而不是报错 —— 看权限「没问题」, + * 但 `zoneIdFor()` 永远查不到 zone,DNS-01 就会一直失败。 + * 配上固定 Zone ID 就能绕开列举这一步(`/zones//dns_records` 只要 + * 该 zone 在授权范围内就能用)。 + */ + private readonly fixedZoneId?: string, + private readonly fixedZoneName?: string, + ) {} + + private async call(path: string, init: RequestInit = {}): Promise { + const r = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + ...init, + headers: { + Authorization: `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json', + ...(init.headers || {}), + }, + }); + const text = await r.text(); + let d: { success?: boolean; errors?: { code?: number; message?: string }[]; result?: unknown }; + try { + d = JSON.parse(text); + } catch { + throw new Error(`Cloudflare 返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`); + } + if (!d.success) { + const errs = d.errors || []; + const msg = errs.map((e) => `${e.code ?? ''} ${e.message ?? ''}`.trim()).join('; '); + // ★ 403 在 DNS 这条路上几乎只有两个原因,直接说清楚省得来回猜 + if (r.status === 403) { + throw new Error( + `Cloudflare 拒绝访问(403):${msg || '权限不足'} —— ` + + `该 Token 缺少「Zone → DNS → Edit」权限,或未把域名加入 Token 的 Zone Resources`, + ); + } + throw new Error(`Cloudflare 调用失败(HTTP ${r.status}):${msg}`); + } + return d.result as T; + } + + /** 从 fqdn 里找出属于本 token 的 zone id */ + private async zoneIdFor(fqdn: string): Promise<{ zoneId: string; root: string }> { + // ① 配置里写死了 zone id → 直接用,连列举都省了 + if (this.fixedZoneId) { + return { zoneId: this.fixedZoneId, root: this.fixedZoneName || fqdn }; + } + + const parts = fqdn.replace(/\.$/, '').toLowerCase().split('.'); + // ② 从最长后缀往短试:先试 a.b.c.com,再 b.c.com,再 c.com + for (let i = 0; i < parts.length - 1; i++) { + const guess = parts.slice(i).join('.'); + const zones = await this.call<{ id: string; name: string }[]>(`/zones?name=${encodeURIComponent(guess)}`); + if (zones && zones.length) return { zoneId: zones[0].id, root: zones[0].name }; + } + + // ③ 走到这里通常是「token 被限制到具体 zone」或「缺 Zone:Read」—— + // 两者都表现为「列举返回空」,但处置完全不同,所以把话写全。 + throw new Error( + `Cloudflare 里找不到 ${fqdn} 所属的 Zone。` + + `常见原因:① Token 缺少「Zone → Zone → Read」权限(列举会是空数组而不报错);` + + `② Token 的 Zone Resources 没包含这个域名;` + + `③ 该域名的 DNS 并不托管在这个 Cloudflare 账号下。` + + `若不确定,可在凭据里直接填 zoneId 跳过列举。`, + ); + } + + async addTxt(fqdn: string, value: string): Promise { + const { zoneId } = await this.zoneIdFor(fqdn); + await this.call(`/zones/${zoneId}/dns_records`, { + method: 'POST', + body: JSON.stringify({ type: 'TXT', name: fqdn, content: value, ttl: 120 }), + }); + } + + async delTxt(fqdn: string, value: string): Promise { + const { zoneId } = await this.zoneIdFor(fqdn); + const recs = await this.call<{ id: string; content: string }[]>( + `/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(fqdn)}`, + ); + for (const rec of recs || []) { + // CF 会自己加引号,比较时剥掉 + if (rec.content.replace(/^"|"$/g, '') === value) { + await this.call(`/zones/${zoneId}/dns_records/${rec.id}`, { method: 'DELETE' }); + } + } + } + + async listTxt(fqdn: string): Promise { + const { zoneId } = await this.zoneIdFor(fqdn); + const recs = await this.call<{ content: string }[]>( + `/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(fqdn)}`, + ); + return (recs || []).map((r) => r.content.replace(/^"|"$/g, '')); + } +} + +// ==================================================================== 工厂 + +/** + * 按凭据记录造一个 DNS 客户端。 + * ★ 只认识 tencentcloud / cloudflare 两类;其余类型抛错而不是静默返回 null —— + * 静默返回会让「配置错了」表现为「验证一直 pending 到超时」,排查起来很痛苦。 + */ +export function makeDnsProvider(rec: AccessRecord): DnsProvider { + const t = String(rec.type || ''); + if (t === 'tencentcloud') { + const id = String(rec.secretId || ''); + const key = String(rec.secretKey || ''); + if (!id || !key) throw new Error('腾讯云凭据缺少 secretId / secretKey'); + return new TencentDns(id, key); + } + if (t === 'cloudflare') { + const token = String(rec.apiToken || ''); + if (!token) throw new Error('Cloudflare 凭据缺少 apiToken'); + // zoneId / zoneName 可选;填了就不去列举 zone(见 CloudflareDns 构造器注释) + return new CloudflareDns( + token, + rec.zoneId ? String(rec.zoneId) : undefined, + rec.zoneName ? String(rec.zoneName) : undefined, + ); + } + throw new Error(`DNS-01 不支持凭据类型「${t}」(目前只支持 tencentcloud / cloudflare)`); +} + +// ==================================================================== crypto 小工具 + +const enc = (s: string) => new TextEncoder().encode(s); + +async function sha256Hex(s: string): Promise { + return bufToHex(await crypto.subtle.digest('SHA-256', enc(s))); +} + +async function hmacSha256(key: ArrayBuffer | Uint8Array, data: string): Promise { + // ★ TS 5.7 起 Uint8Array 是泛型(Uint8Array), + // 而 crypto.subtle.importKey 要 ArrayBufferView。 + // 统一转成 Uint8Array 再取 .buffer(保证是 ArrayBuffer,不是 SharedArrayBuffer)。 + const bytes = key instanceof Uint8Array ? key : new Uint8Array(key); + const raw = new Uint8Array(bytes).buffer as ArrayBuffer; + const k = await crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + return crypto.subtle.sign('HMAC', k, enc(data)); +} + +function bufToHex(buf: ArrayBuffer): string { + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/blog-admin/src/routes/ssl.ts b/blog-admin/src/routes/ssl.ts index c1bd262c..ff656a6f 100644 --- a/blog-admin/src/routes/ssl.ts +++ b/blog-admin/src/routes/ssl.ts @@ -27,6 +27,7 @@ import { clearLog, delAccess, delCert, + getAccess, getCert, listAccess, listCertNames, @@ -41,6 +42,10 @@ import { type KeeperConfig, } from '../lib/certstore'; import { daysLeft, parsePemInfo, probeTls } from '../lib/certprobe'; +import { issueDomain, RENEW_BEFORE_DAYS, type IssueOutcome } from '../lib/certissue'; +import { AcmeClient } from '../lib/acme'; +import { makeDnsProvider } from '../lib/dnsprovider'; +import { makeDeployer, OnePanelDeployer } from '../lib/deployer'; import { mailEnabled, sendMail } from '../lib/mail'; import { formatDateCN } from '../lib/util'; @@ -588,6 +593,304 @@ export async function logClear(ctx: Ctx): Promise { return ok({ cleared: true }); } +// ==================================================================== 签发 / 续期 + +/** + * 手动签发一个域名(或强制续期)。 + * + * body: { domain?: string, force?: boolean, noDeploy?: boolean } + * · domain 省略 → 对所有启用的域名跑一遍续期检查 + * · force=true → 忽略剩余天数,强制重签 + * · noDeploy → 只签不部署(调试) + * + * ★ 这是本模块唯一会**真正签发证书**的入口,也是耗时最长的(DNS 传播等待 + * 30s × 授权数 + 轮询),单个域名通常 1~3 分钟。前端要给出明确的进行中提示。 + */ +export async function certIssue(ctx: Ctx): Promise { + const w = await writeAuth(ctx); + 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 }); +} + +/** 只看「该不该续期」,不签发 —— 给 UI 的「检查」按钮用,秒回 */ +export async function certRenewCheck(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + const cfg = await loadConfig(ctx.env); + const items = []; + for (const d of cfg.domains) { + if (d.disabled) { + items.push({ domain: d.name, action: 'skip', reason: '已停用' }); + 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); + const left = daysLeft(live.notAfter); + // ★ 判定「线上真实剩余天数」而不是 KV 里那份:KV 可能过期/失同步, + // 线上才决定读者会不会看到证书过期。 + const base = left !== null ? left : daysLeft(rec?.expireAt); + items.push({ + domain: d.name, + host, + source: left !== null ? 'live' : 'stored', + daysLeft: base, + action: base === null ? 'issue' : base <= RENEW_BEFORE_DAYS ? 'renew' : 'ok', + threshold: RENEW_BEFORE_DAYS, + issuer: live.issuer || rec?.issuer || '', + error: live.ok ? null : live.error || null, + }); + } + return ok({ items, threshold: RENEW_BEFORE_DAYS }); +} + +// ==================================================================== 自检 + +interface CheckItem { + name: string; + kind: 'ca' | 'dns' | 'deploy' | 'target'; + ok: boolean; + detail: string; + /** 出问题时的处置建议 */ + hint?: string; +} + +/** + * 环境自检 —— 把所有「续期时才可能暴露」的配置问题提前查出来。 + * + * ★ 为什么需要这个:证书续期是**无人值守**的。一次配置错误(目录地址少个 + * `/v2`、API Key 过期、1Panel 忘了加 IP 白名单)在平时完全看不出来, + * 等到证书真过期那天才发现 —— 那时站点已经在报错了。 + * 这个接口让管理员在配完之后立刻能验证全套链路。 + * + * ★ 这里**故意不真正签发**(不消耗 CA 配额、不改 DNS):只做 + * 「能不能连上 / 认不认凭据」级别的探测。 + * - CA:拉一次目录,看结构是否完整、是否要求 EAB + * - DNS:只做一次只读列举(不写 TXT),验证签名与权限 + * - 部署:只读列举(多吉云不做写操作、1Panel 不绑站点) + */ +export async function certSelfCheck(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + + const env = ctx.env; + const cfg = await loadConfig(env); + const items: CheckItem[] = []; + + // ---- ① 各 CA(acme-eab 凭据)---- + const accesses = await listAccess(env); + const eabNames = accesses.filter((x) => x.type === 'acme-eab' && !x.unreadable).map((x) => x.name); + for (const name of eabNames) { + const rec = await getAccess(env, name); + const url = String(rec?.directoryUrl || ''); + if (!url) { + items.push({ name: `CA ${name}`, kind: 'ca', ok: false, detail: '缺少 directoryUrl' }); + continue; + } + try { + // 用一个临时账户密钥探测目录(不注册,纯读) + const probe = new AcmeClient(url, { jwk: { kty: 'EC', crv: 'P-256', x: 'AA', y: 'AA' } as JsonWebKey, kid: '' }); + const needEab = await probe.externalAccountRequired(); + const hasEab = !!(rec?.eabKid && rec?.eabHmacKey); + if (needEab && !hasEab) { + items.push({ + name: `CA ${name}`, + kind: 'ca', + ok: false, + detail: `目录可达,但该 CA 要求 EAB 而凭据里没有 eabKid/eabHmacKey`, + hint: '到 CA 后台重新生成 EAB 凭据并补进这条凭据', + }); + } else { + items.push({ + name: `CA ${name}`, + kind: 'ca', + ok: true, + detail: `目录可达${needEab ? ',EAB 已配对' : ',无需 EAB'}`, + }); + } + } catch (e) { + items.push({ + name: `CA ${name}`, + kind: 'ca', + ok: false, + detail: errMsg(e), + hint: '核对 directoryUrl 是否完整(多数 CA 需要 /v2 之类的版本段)', + }); + } + } + if (!eabNames.length) { + items.push({ name: 'CA', kind: 'ca', ok: false, detail: '没有任何 acme-eab 凭据,无法签发' }); + } + + // ---- ② 各 DNS 凭据(只读列举,验证签名/权限)---- + for (const d of cfg.domains) { + const key = `DNS ${d.dns}`; + if (items.some((x) => x.name === key)) continue; + const rec = await getAccess(env, d.dns); + if (!rec) { + items.push({ name: key, kind: 'dns', ok: false, detail: `凭据「${d.dns}」不存在` }); + continue; + } + const host = d.san.find((s) => !s.startsWith('*.')) || d.name; + try { + const dns = makeDnsProvider(rec); + // ★ 用 _acme-challenge.<主域> 做只读列举:既验证签名有效, + // 也验证「这条凭据确实管得着这个域名」——后者才是真正会翻车的点 + const existing = await dns.listTxt(`_acme-challenge.${host}`); + items.push({ + name: key, + kind: 'dns', + ok: true, + detail: `${rec.type} 凭据可用,能读取 ${host} 的 TXT(现存 ${existing.length} 条)`, + }); + } catch (e) { + items.push({ + name: key, + kind: 'dns', + ok: false, + detail: errMsg(e), + hint: '确认密钥有效、且该域名确实在这条凭据的账号下', + }); + } + } + + // ---- ③ 各部署目标 ---- + const targets = new Set(); + for (const d of cfg.domains) for (const t of d.deploy) targets.add(t); + for (const t of targets) { + const credName = t === '1panel' ? '1panel-cn' : t; + const rec = await getAccess(env, credName); + if (!rec) { + items.push({ name: `部署 ${t}`, kind: 'deploy', ok: false, detail: `凭据「${credName}」不存在` }); + continue; + } + try { + const dp = makeDeployer(rec); + if (t === '1panel') { + // ★ 这里要**真**打一次 1Panel 接口 —— 只看「凭据能构造出来」是不够的: + // 1Panel 开了「安全登录」之后,面板 API 会搬到随机入口路径下, + // 根路径只回一个 HTML 提示页(HTTP 200,不是错误码)。 + // 不实探的话,这个问题要到证书该续期那天才会暴露。 + const panel = new OnePanelDeployer( + String((rec as { serverUrl?: string }).serverUrl || ''), + String((rec as { apiKey?: string }).apiKey || ''), + String((rec as { apiVersion?: string }).apiVersion || 'v1') === 'v2' ? 'v2' : 'v1', + ); + const probe = await panel.ping(); + if (!probe.ok) { + items.push({ + name: `部署 ${t}`, + kind: 'deploy', + ok: false, + detail: probe.error || '1Panel 不可用', + hint: probe.hint, + }); + } else { + const sites = new Set(); + for (const d of cfg.domains) for (const s of d.one_panel_sites || []) sites.add(s); + const found: string[] = []; + for (const s of sites) { + try { + const w = await panel.inspectSite(s); + found.push(`${s}→#${w.id}${w.enable ? `(现绑 ${w.certCN || '?'})` : '(未开 HTTPS)'}`); + } catch { + found.push(`${s}→未找到`); + } + } + const bad = found.filter((x) => x.includes('未找到')); + items.push({ + name: `部署 ${t}`, + kind: 'deploy', + ok: bad.length === 0, + detail: + `1Panel 可达(API ${String((rec as { apiVersion?: string }).apiVersion || 'v2')});` + + (found.length ? `站点匹配:${found.join(',')}` : '未配置待绑定站点'), + hint: bad.length + ? `这些站点名在 1Panel 里找不到,部署会跳过:${bad.map((x) => x.split('→')[0]).join(', ')}` + : undefined, + }); + } + } else { + items.push({ + name: `部署 ${t}`, + kind: 'deploy', + ok: true, + detail: `${dp.kind} 凭据已构造成功${ + (rec as { accessKey?: string }).accessKey + ? `(AK ${String((rec as { accessKey?: string }).accessKey).slice(0, 4)}…)` + : '' + }`, + }); + } + } catch (e) { + items.push({ name: `部署 ${t}`, kind: 'deploy', ok: false, detail: errMsg(e) }); + } + } + + // ---- ④ 域名配置本身的完整性 ---- + for (const d of cfg.domains) { + if (d.disabled) { + items.push({ name: `域名 ${d.name}`, kind: 'target', ok: true, detail: '已停用(不参与自动续期)' }); + continue; + } + if (!d.san.length) { + items.push({ + name: `域名 ${d.name}`, + kind: 'target', + ok: false, + detail: '没有配置 SAN,签发时只会覆盖主域名', + hint: '需要覆盖子域时在 SAN 里补上(如 usj.cc, *.usj.cc)', + }); + continue; + } + if (!d.deploy.length) { + items.push({ + name: `域名 ${d.name}`, + kind: 'target', + ok: true, + detail: '只签发不部署(deploy 为空)', + }); + continue; + } + items.push({ + name: `域名 ${d.name}`, + kind: 'target', + ok: true, + detail: `${d.san.length} 个 SAN,部署到 ${d.deploy.join(' + ')}`, + }); + } + + return ok({ + items, + summary: { + total: items.length, + failed: items.filter((x) => !x.ok).length, + }, + threshold: RENEW_BEFORE_DAYS, + }); +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + // ==================================================================== 会话 /** @@ -630,6 +933,13 @@ export const SSL_ROUTES: { method: string; path: string; handler: (ctx: Ctx) => { method: 'POST', path: '/ssl/notify', handler: certNotify }, { method: 'GET', path: '/ssl/log', handler: logList }, { method: 'POST', path: '/ssl/log/clear', handler: logClear }, + // ★ 签发/续期:POST /ssl/issue 是**真正下单**的那个(慢,1~3 分钟/域名) + { method: 'GET', path: '/ssl/renew-check', handler: certRenewCheck }, + { method: 'POST', path: '/ssl/renew-check', handler: certRenewCheck }, + { method: 'POST', path: '/ssl/issue', handler: certIssue }, + // ★ 环境自检:不签发、不写 DNS,只验证全套凭据「连得上、认得对」 + { method: 'GET', path: '/ssl/selfcheck', handler: certSelfCheck }, + { method: 'POST', path: '/ssl/selfcheck', handler: certSelfCheck }, ]; export type { UserRow }; diff --git a/blog-admin/tools/livecheck-adapters.mjs b/blog-admin/tools/livecheck-adapters.mjs new file mode 100644 index 00000000..848afaf5 --- /dev/null +++ b/blog-admin/tools/livecheck-adapters.mjs @@ -0,0 +1,337 @@ +/** + * DNS / 部署适配层**实连**自测(只读)。 + * + * ★ 为什么值得单独写:离线单测只能证明「算法对」,证明不了「凭据对、权限对、 + * 接口形状对」。而这三样恰恰是续期无人值守时最容易静默失败的地方。 + * 这里用**真凭据**打真接口,但**只做只读操作** —— + * 不写 DNS 记录、不签发证书、不绑站点,跑一百遍也不会有副作用。 + * + * 跑法(凭据从仓库外读,本文件不含任何密钥): + * node tools/livecheck-adapters.mjs # 全部检查 + * node tools/livecheck-adapters.mjs dns # 只查 DNS + * node tools/livecheck-adapters.mjs deploy # 只查部署 + * + * ★ 结果里对密钥一律脱敏(只留尾 4 位),日志可以直接贴出来。 + */ +import { readFileSync } from 'node:fs'; + +const ONLY = process.argv[2] || 'all'; +const SEEDS = JSON.parse(readFileSync('E:/GitHub/secrets-backup/certkeeper-seeds.json', 'utf8')); + +let pass = 0; +let fail = 0; +const t = (name, ok, detail = '') => { + if (ok) { + pass++; + console.log(` ✓ ${name}${detail ? ' — ' + detail : ''}`); + } else { + fail++; + console.log(` ✗ ${name}${detail ? ' — ' + detail : ''}`); + } +}; + +const mask = (v) => (v ? `${String(v).slice(0, 4)}…${String(v).slice(-4)}` : ''); +const byName = (n) => SEEDS.access.find((a) => a.name === n); +const rec = (n) => { + const a = byName(n); + return a ? { type: a.type, note: a.note, ...a.fields } : null; +}; + +// 沙箱里代理会让部分直连被拦,统一清掉 +for (const k of ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY']) delete process.env[k]; + +// ============================================================ DNS + +async function checkDns(name, host) { + console.log(`\n[DNS] ${name} → 读 _acme-challenge.${host}`); + const r = rec(name); + if (!r) return t(`${name} 凭据存在`, false, 'seeds 里没有'); + + if (r.type === 'tencentcloud') { + const id = r.secretId; + const key = r.secretKey; + const ts = Math.floor(Date.now() / 1000); + const date = new Date(ts * 1000).toISOString().slice(0, 10); + const HOST = 'dnspod.tencentcloudapi.com'; + const service = 'dnspod'; + + const sha256hex = async (s) => + Buffer.from(await crypto.subtle.digest('SHA-256', Buffer.from(s, 'utf8'))).toString('hex'); + const hmac = async (k, d) => + Buffer.from(await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', k, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']), Buffer.from(d, 'utf8'))); + + // 先列根域名,确认凭据有效 + const call = async (action, payload) => { + const body = JSON.stringify(payload); + const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${HOST}\n`; + const signedHeaders = 'content-type;host'; + const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, await sha256hex(body)].join('\n'); + const scope = `${date}/${service}/tc3_request`; + const stringToSign = ['TC3-HMAC-SHA256', String(ts), scope, await sha256hex(canonicalRequest)].join('\n'); + let k = await hmac(Buffer.from(`TC3${key}`, 'utf8'), date); + k = await hmac(k, service); + k = await hmac(k, 'tc3_request'); + const sig = (await hmac(k, stringToSign)).toString('hex'); + const res = await fetch(`https://${HOST}/`, { + method: 'POST', + headers: { + Authorization: `TC3-HMAC-SHA256 Credential=${id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`, + 'Content-Type': 'application/json; charset=utf-8', + Host: HOST, + 'X-TC-Action': action, + 'X-TC-Version': '2021-03-23', + 'X-TC-Timestamp': String(ts), + }, + body, + }); + return res.json(); + }; + + try { + const list = await call('DescribeDomainList', {}); + if (list.Response?.Error) { + t(`${name} 凭据有效`, false, `${list.Response.Error.Code} ${list.Response.Error.Message}`); + return; + } + const roots = (list.Response?.DomainList || []).map((d) => d.Name); + t(`${name} 凭据有效(AK ${mask(id)})`, roots.length > 0, `可见 ${roots.length} 个域名:${roots.slice(0, 6).join(', ')}`); + const root = host.split('.').slice(-2).join('.'); + t(`${name} 管得着 ${host}`, roots.includes(root), roots.includes(root) ? `含 ${root}` : `未见 ${root}`); + if (roots.includes(root)) { + const rl = await call('DescribeRecordList', { Domain: root, Subdomain: `_acme-challenge`, RecordType: 'TXT' }); + const code = rl.Response?.Error?.Code || ''; + // ★ `ResourceNotFound.NoDataOfRecord` = 「这个名字下没有记录」—— + // 对 _acme-challenge 来说这正是**健康**状态(签发完就删干净了)。 + // 把它当失败会永远报红,反而掩盖真问题。 + const noData = code === 'ResourceNotFound.NoDataOfRecord'; + t( + `${name} 能读 TXT 记录`, + !rl.Response?.Error || noData, + rl.Response?.Error + ? noData + ? '查询成功,_acme-challenge 下无残留记录(正常)' + : `${code} ${rl.Response.Error.Message}` + : `_acme-challenge 下现存 ${(rl.Response?.RecordList || []).length} 条`, + ); + } + } catch (e) { + t(`${name} 请求成功`, false, e.message); + } + return; + } + + if (r.type === 'cloudflare') { + try { + const h = { Authorization: `Bearer ${r.apiToken}` }; + const verify = await (await fetch('https://api.cloudflare.com/client/v4/user/tokens/verify', { headers: h })).json(); + t(`${name} token 处于 active`, verify.success === true && verify.result?.status === 'active', verify.result?.status || 'invalid'); + + // ★ 关键一步:`GET /zones`(不带 name)。被限制到具体 zone 的 token + // 会返回 **200 + 空数组**而不是报错 —— 光看这里会误判成「没问题」。 + const zones = await (await fetch('https://api.cloudflare.com/client/v4/zones?per_page=50', { headers: h })).json(); + const visible = zones.result || []; + t( + `${name} 能列举 Zone(Zone:Read)`, + zones.success === true && visible.length > 0, + zones.success + ? visible.length + ? `可见 ${visible.length} 个:${visible.map((z) => z.name).slice(0, 6).join(', ')}` + : '返回 200 但列表为空 → token 被限制到具体 zone,或缺少 Zone:Read' + : JSON.stringify(zones.errors || []).slice(0, 160), + ); + + const root = host.split('.').slice(-2).join('.'); + const z = visible.find((x) => x.name === root); + t(`${name} 管得着 ${host}`, !!z, z ? `zone ${z.name}(${z.id})` : `可见列表里没有 ${root}`); + if (z) { + const rr = await (await fetch( + `https://api.cloudflare.com/client/v4/zones/${z.id}/dns_records?type=TXT&name=${encodeURIComponent('_acme-challenge.' + host)}`, + { headers: h }, + )).json(); + t(`${name} 能读 TXT 记录`, rr.success === true, rr.success ? `现存 ${(rr.result || []).length} 条` : JSON.stringify(rr.errors || {}).slice(0, 160)); + } else { + console.log(' ↳ 这条凭据**无法用于 DNS-01**。CF DNS 校验需要「Zone → DNS → Edit」权限'); + console.log(' 并且 Zone Resources 要包含 200181.xyz;若只想给这一条 zone 用,'); + console.log(' 可以再在凭据里补 zoneId(适配层支持跳过列举)。'); + } + } catch (e) { + t(`${name} 请求成功`, false, e.message); + } + } +} + +// ============================================================ 部署 + +async function check1Panel() { + console.log(`\n[部署] 1panel-cn → ${rec('1panel-cn')?.serverUrl}`); + const r = rec('1panel-cn'); + if (!r) return t('1panel-cn 凭据存在', false, 'seeds 里没有'); + + const ts = String(Math.floor(Date.now() / 1000)); + const md5 = (await import('node:crypto')).createHash('md5').update(`1panel${r.apiKey}${ts}`).digest('hex'); + // ★ 必须用 v2:实测 v1 会返回 **HTTP 200 + 一个 HTML 提示页** + // (`Access Temporarily Unavailable`),看起来像限流, + // 其实是 v1 已停用、面板把「路径不对」渲染成了那个页面。 + const VER = r.apiVersion || 'v2'; + const call = async (path, method = 'POST', body) => { + const res = await fetch(`${r.serverUrl}/api/${VER}${path}`, { + method, + headers: { + '1Panel-Token': md5, + '1Panel-Timestamp': ts, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + // 不显式识别 HTML 的话,会被 JSON.parse 的 catch 吞成空对象 → + // 看起来「接口通、只是没数据」,非常容易误判。 + if (/Access Temporarily Unavailable|secure login access| + w.primaryDomain === key || + (w.alias || '').split(',').map((s) => s.trim()).includes(key) || + String(w.id) === key, + ); + t(`1Panel 里能找到站点「${key}」`, !!hit, hit ? `→ 网站 #${hit.id}(${hit.primaryDomain})` : '未找到,部署时会失败'); + } + } catch (e) { + t('1Panel 请求成功', false, e.message); + } +} + +async function checkDogeCloud() { + const r = rec('dogecloud'); + console.log(`\n[部署] dogecloud → AK ${mask(r?.accessKey)}`); + if (!r) return t('dogecloud 凭据存在', false, 'seeds 里没有'); + + const HOST = 'https://api.dogecloud.com'; + const call = async (path, body) => { + const bodyStr = JSON.stringify(body); + const sig = (await import('node:crypto')).createHmac('sha1', r.secretKey).update(`${path}\n${bodyStr}`).digest('hex'); + const res = await fetch(HOST + path, { + method: 'POST', + headers: { Authorization: `TOKEN ${r.accessKey}:${sig}`, 'Content-Type': 'application/json' }, + body: bodyStr, + }); + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return { code: res.status, msg: text.slice(0, 160) }; + } + }; + + try { + // ★ 列 CDN 域名用 `/cdn/domain/list.json`。 + // `/cdn/domain.json` 是「查单个域名」,不带 domain 会回 `400 domain 格式错误`—— + // 实测踩过一次,别被它的名字骗了。 + const d = await call('/cdn/domain/list.json', {}); + t('多吉云签名有效(API 可达)', d.code === 200, d.code === 200 ? '' : `code=${d.code} ${d.msg || ''}`); + if (d.code !== 200) { + console.log(' ↳ 签名必须用 HMAC-**SHA1** + hex,Authorization 形如 `TOKEN :`'); + return; + } + const domains = d.data?.domains || []; + t('多吉云能列 CDN 域名', Array.isArray(domains), `${domains.length} 个`); + for (const x of domains) { + console.log(` · id=${x.id} ${x.name} cname=${x.cname || '—'}`); + } + + // 证书列表(只读)—— 顺便看当前线上挂的是哪张、什么时候到期 + const cl = await call('/cdn/cert/list.json', {}); + t('多吉云能列证书', cl.code === 200, cl.code === 200 ? `${(cl.data?.certs || []).length} 张` : `code=${cl.code} ${cl.msg || ''}`); + for (const c of (cl.data?.certs || []).slice(0, 6)) { + const exp = c.expire ? new Date(c.expire * 1000).toISOString().slice(0, 10) : '—'; + console.log(` · #${c.id} ${c.note || ''} 域名=${(c.domains || []).length} 到期=${exp}`); + } + } catch (e) { + t('多吉云请求成功', false, e.message); + } +} + +// ============================================================ 主流程 + +console.log('='.repeat(64)); +console.log('适配层实连自测(只读,无副作用)'); +console.log('='.repeat(64)); + +if (ONLY === 'all' || ONLY === 'dns') { + await checkDns('tencent-usj', 'usj.cc'); + await checkDns('tencent-tt', 't-t.live'); + await checkDns('cloudflare', '200181.xyz'); +} +if (ONLY === 'all' || ONLY === 'deploy') { + await check1Panel(); + await checkDogeCloud(); +} + +console.log('\n' + '='.repeat(64)); +console.log(`通过 ${pass} / ${pass + fail}`); +process.exit(fail ? 1 : 0); diff --git a/blog-admin/tools/probe-ssl-ui.mjs b/blog-admin/tools/probe-ssl-ui.mjs index 72664d04..3db86e3e 100644 --- a/blog-admin/tools/probe-ssl-ui.mjs +++ b/blog-admin/tools/probe-ssl-ui.mjs @@ -56,6 +56,27 @@ const MOCK = { notAfter: Date.now() + 62 * DAY, notBefore: Date.now() - 28 * DAY, daysLeft: 62, notAfterText: '2026-12-07 06:59:59', }, + // 续期判定(只读) + '/api/v2/ssl/renew-check': { + threshold: 30, + items: [ + { domain: 'usj.cc', host: 'usj.cc', source: 'live', daysLeft: 62, action: 'ok', threshold: 30, issuer: 'TrustAsia Technologies, Inc.', error: null }, + { domain: 't-t.live', host: 't-t.live', source: 'live', daysLeft: 83, action: 'ok', threshold: 30, issuer: "Let's Encrypt", error: null }, + { domain: '200181.xyz', host: '200181.xyz', source: 'live', daysLeft: 19, action: 'renew', threshold: 30, issuer: 'LiteSSL', error: null }, + ], + }, + // 环境自检(只读) + '/api/v2/ssl/selfcheck': { + threshold: 30, + summary: { total: 5, failed: 0 }, + items: [ + { name: 'CA litessl', kind: 'ca', ok: true, detail: '目录可达,EAB 已配对' }, + { name: 'DNS tencent-usj', kind: 'dns', ok: true, detail: 'tencentcloud 凭据可用,能读取 usj.cc 的 TXT(现存 0 条)' }, + { name: 'DNS cloudflare', kind: 'dns', ok: true, detail: 'cloudflare 凭据可用,能读取 200181.xyz 的 TXT(现存 0 条)' }, + { name: '部署 dogecloud', kind: 'deploy', ok: true, detail: '多吉云凭据可用' }, + { name: '部署 1panel', kind: 'deploy', ok: true, detail: '1Panel 凭据可用,站点 usj.cc 已找到' }, + ], + }, '/api/v2/ssl/access': { items: [ { name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } }, @@ -229,6 +250,23 @@ try { t('「实测」按钮存在', view.includes('ssl-probe')); t('「登记」按钮存在', view.includes('ssl-import')); t('「+ 域名」按钮存在', view.includes('ssl-add-domain')); + // 本轮新增的三个动作 + t('★ 「环境自检」按钮存在', view.includes('ssl-selfcheck')); + t('★ 「该续期了吗」按钮存在', view.includes('ssl-renew-check')); + t('★ 每行有「签发」按钮', view.includes('data-act="ssl-issue"')); + + // ---------- 环境自检弹窗(本轮新增) + { + const before = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`)); + await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`); + await sleep(1500); + const m = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`)); + t('★ 自检弹窗打开', m !== before && m.includes('环境自检'), m.slice(0, 80)); + t('★ 自检列出各项', m.includes('CA') && m.includes('DNS') && m.includes('部署')); + t('★ 自检说明「不会签发证书」', m.includes('不会签发证书')); + await ev(`(() => { const b = document.querySelector('#modal button[data-act="modal-close"]'); if (b) b.click(); })()`); + await sleep(400); + } // ---------- 凭据区 await sleep(600); @@ -302,8 +340,8 @@ try { await sleep(3000); await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`); await sleep(2000); - // 点一次「实测」——本轮改的就是探测链路,把结果弹窗截进画面才看得见。 - await ev(`(() => { const b = document.querySelector('button[data-act="ssl-probe"]'); if (b) b.click(); })()`); + // 点一次「环境自检」——本轮新增的核心能力,把结果弹窗截进画面。 + await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`); await sleep(2000); await ev(`document.documentElement.setAttribute('data-theme','light')`); await sleep(500); diff --git a/blog-admin/tools/seed-ssl-config.mjs b/blog-admin/tools/seed-ssl-config.mjs index f3929a3b..419df545 100644 --- a/blog-admin/tools/seed-ssl-config.mjs +++ b/blog-admin/tools/seed-ssl-config.mjs @@ -85,6 +85,17 @@ const CONFIG = { san: ['usj.cc', '*.usj.cc'], dns: 'tencent-usj', deploy: ['dogecloud', '1panel'], + // ★ 站点名必须与 1Panel 里的 `primaryDomain` 或 `alias` 精确对上, + // 否则部署时会被跳过。下面这些是 2026-10-06 从面板实测出来的 + // (面板上**没有** primaryDomain 为 `usj.cc` 的网站 —— 它只是证书名)。 + dogecloud_domains: ['usj.cc', 'www.usj.cc', 'artalk.usj.cc'], + one_panel_sites: [ + 'artalk.usj.cc', // blog 评论后端 + 'openlist.usj.cc', // 网盘 + 'wifi.usj.cc', + 'openwrt.usj.cc', + 'vw.usj.cc', // vaultwarden(alias 才是这个名字) + ], disabled: false, }, { @@ -92,6 +103,7 @@ const CONFIG = { san: ['t-t.live', '*.t-t.live'], 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'], disabled: false, }, { @@ -99,6 +111,7 @@ const CONFIG = { san: ['200181.xyz', '*.200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], + one_panel_sites: ['ssh.200181.xyz'], disabled: false, }, ], @@ -152,6 +165,10 @@ console.log('域名配置(明文):'); for (const d of CONFIG.domains) { const dnsOk = ACCESS.some((a) => a.name === d.dns); console.log(` ${dnsOk ? '✓' : '✗'} ${d.name.padEnd(14)} dns=${d.dns.padEnd(12)} deploy=[${d.deploy.join(', ')}] SAN=${d.san.join(';')}`); + // ★ 把「要绑到哪些站点」也打出来 —— 这是最容易配错、且错了之后 + // 只会在部署时才暴露的一环(站点名对不上 = 静默跳过,站上还是旧证书)。 + if (d.dogecloud_domains?.length) console.log(` 多吉云域名: ${d.dogecloud_domains.join(', ')}`); + if (d.one_panel_sites?.length) console.log(` 1Panel 站点: ${d.one_panel_sites.join(', ')}`); if (!dnsOk) throw new Error(`域名「${d.name}」引用的 DNS 凭据「${d.dns}」不在凭据清单里`); } diff --git a/blog-admin/tools/selftest-acme.mjs b/blog-admin/tools/selftest-acme.mjs new file mode 100644 index 00000000..daf10219 --- /dev/null +++ b/blog-admin/tools/selftest-acme.mjs @@ -0,0 +1,158 @@ +/** + * ACME 客户端离线自测 —— 不联网,只验最容易错的两块: + * ① CSR 的 DER 编码(用 Node 的 openssl 交叉验证) + * ② JWS 的 ES256 签名与 JWK thumbprint(用 Node crypto 独立复算) + * + * 跑法:node tools/selftest-acme.mjs + * + * ★ 2026-10-06 修正两处: + * · `execFileSync` 在本机沙箱里**必抛 EBUSY**(同步 spawn 一律被杀), + * 改用 `execFile` + promisify 的异步版本 —— 同一个坑在 + * editor-api/test/role-perm.mjs 里也踩过一次。 + * · CSR 长度断言原写 >300,实测 EC P-256 的 CSR 只有 250 字节左右 + * (RSA 才上千)。改成 >200 —— 断言要卡在「结构明显不对」而不是 + * 「我以为应该多大」。 + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const pexec = promisify(execFile); + +let pass = 0; +const fails = []; +function t(name, cond, extra = '') { + if (cond) { pass++; console.log(' ✓ ' + name); } + else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ✗ ' + name + (extra ? ' → ' + extra : '')); } +} + +// ---- 复刻 acme.ts 里的 DER 编码器(保持一致才有意义)---- +const enc = new TextEncoder(); +const concat = (...a) => { const n = a.reduce((x, y) => x + y.length, 0); const o = new Uint8Array(n); let f = 0; for (const x of a) { o.set(x, f); f += x.length; } return o; }; +function wrap(tag, c) { + const len = c.length; let lb; + if (len < 0x80) lb = new Uint8Array([len]); + else if (len < 0x100) lb = new Uint8Array([0x81, len]); + else if (len < 0x10000) lb = new Uint8Array([0x82, len >> 8, len & 0xff]); + else lb = new Uint8Array([0x83, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff]); + return concat(new Uint8Array([tag]), lb, c); +} +const seq = (...p) => wrap(0x30, concat(...p)); +const setOf = (...p) => wrap(0x31, concat(...p)); +const octetStr = (b) => wrap(0x04, b); +const bitStr = (b) => wrap(0x03, concat(new Uint8Array([0x00]), b)); +const rawTag = (t, b) => wrap(t, b); +function oid(dotted) { + const parts = dotted.split('.').map(Number); + const body = [40 * parts[0] + parts[1]]; + for (const v of parts.slice(2)) { const st = [v & 0x7f]; let x = v >> 7; while (x > 0) { st.unshift((x & 0x7f) | 0x80); x >>= 7; } body.push(...st); } + return wrap(0x06, new Uint8Array(body)); +} +function intBytes(b) { return wrap(0x02, b[0] & 0x80 ? concat(new Uint8Array([0]), b) : b); } +const trimZ = (b) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; return b.slice(i); }; +const b64u = (b) => Buffer.from(b).toString('base64url'); +const b64uToBytes = (s) => new Uint8Array(Buffer.from(s, 'base64url')); + +// 用异步 execFile —— 本机(Windows + 沙箱)同步 spawn 一律 EBUSY +function openssl(args) { + return new Promise((resolve) => { + execFile('openssl', args, { encoding: 'utf8' }, (err, stdout, stderr) => { + resolve({ err, out: (stdout || '') + (stderr || '') }); + }); + }); +} + +async function makeCsr(kp, domains) { + const cn = domains[0]; + const pub = await crypto.subtle.exportKey('jwk', kp.publicKey); + const x = b64uToBytes(pub.x), y = b64uToBytes(pub.y); + const spki = seq(seq(oid('1.2.840.10045.2.1'), oid('1.2.840.10045.3.1.7')), bitStr(concat(new Uint8Array([4]), x, y))); + const sanExt = seq(oid('2.5.29.17'), octetStr(seq(...domains.map((d) => rawTag(0x82, enc.encode(d)))))); + // ★ 只套三层 seq:Attribute / Extensions / SAN 扩展本身。 + // 多一层(setOf(seq(seq(sanExt))))会让 openssl 报 + // `wrong tag ... Field=object, Type=X509_ATTRIBUTE`。 + const extReq = rawTag(0xa0, seq(oid('1.2.840.113549.1.9.14'), setOf(seq(sanExt)))); + const cri = seq(wrap(0x02, new Uint8Array([0])), seq(setOf(seq(oid('2.5.4.3'), rawTag(0x0c, enc.encode(cn))))), spki, extReq); + const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, kp.privateKey, cri); + const r = trimZ(new Uint8Array(sig).slice(0, 32)), s = trimZ(new Uint8Array(sig).slice(32)); + const derSig = seq(intBytes(r), intBytes(s)); + return seq(cri, seq(oid('1.2.840.10045.4.3.2')), bitStr(derSig)); +} + +console.log('\n[1] CSR 生成与结构验证'); +const kp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); +const domains = ['usj.cc', '*.usj.cc']; +const csr = await makeCsr(kp, domains); +const csrDer = Buffer.from(csr); +t('CSR 以 SEQUENCE 开头(0x30)', csrDer[0] === 0x30, 'got 0x' + csrDer[0].toString(16)); +t('CSR 长度合理(>200 字节)', csrDer.length > 200, String(csrDer.length)); + +// 用 Node 自带的 crypto 交叉验证:能解析说明 DER 结构合法 +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'acme-csr-')); +const csrPem = '-----BEGIN CERTIFICATE REQUEST-----\n' + + (csrDer.toString('base64').match(/.{1,64}/g) || []).join('\n') + + '\n-----END CERTIFICATE REQUEST-----\n'; +const csrFile = path.join(tmp, 'test.csr'); +fs.writeFileSync(csrFile, csrPem); +try { + const { stdout: out } = await pexec('openssl', ['req', '-in', csrFile, '-noout', '-text', '-verify'], { + encoding: 'utf8', + }); + t('openssl 能解析并验签通过', out.includes('verify OK') || out.includes('Certificate Request'), ''); + t('CSR 里含 CN=usj.cc', /CN\s*=\s*usj\.cc|commonName.*usj\.cc/s.test(out), ''); + t('CSR 里含 SAN usj.cc', out.includes('usj.cc'), ''); + t('CSR 里含通配符 *.usj.cc', out.includes('*.usj.cc'), ''); + t('签名算法是 ecdsa-with-SHA256', /ecdsa-with-SHA256|id-ecPublicKey|prime256v1/i.test(out), ''); +} catch (e) { + t('openssl 解析 CSR', false, (e.stderr || e.message || '').slice(0, 200)); +} + +console.log('\n[2] JWS / ES256 验证'); +const accKp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); +const jwk = await crypto.subtle.exportKey('jwk', accKp.privateKey); +const protectedHeader = { alg: 'ES256', nonce: 'abc123', url: 'https://acme.example/new-account', jwk }; +const payload = { termsOfServiceAgreed: true }; +const p64 = Buffer.from(JSON.stringify(protectedHeader)).toString('base64url'); +const pl64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); +const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, accKp.privateKey, enc.encode(p64 + '.' + pl64)); +t('ES256 签名是 64 字节(raw r||s,不是 DER)', sig.byteLength === 64, String(sig.byteLength)); + +// 用 Node crypto 独立验签(JWK → KeyObject) +const pubKey = crypto.createPublicKey({ key: { ...jwk, d: undefined }, format: 'jwk' }); +const ok = crypto.verify('sha256', Buffer.from(p64 + '.' + pl64), { key: pubKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig)); +t('Node crypto 验签通过(ieee-p1363 = 原始 r||s)', ok === true, String(ok)); + +console.log('\n[3] JWK thumbprint(RFC 7638)'); +const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); +const thumb = crypto.createHash('sha256').update(canonical).digest('base64url'); +t('thumbprint 是 43 字符的 base64url', /^[A-Za-z0-9_-]{43}$/.test(thumb), thumb); +t('字段顺序必须是 crv,kty,x,y', canonical.indexOf('crv') < canonical.indexOf('kty') && canonical.indexOf('kty') < canonical.indexOf('x'), canonical.slice(0, 40)); + +console.log('\n[4] key authorization(DNS-01)'); +const token = 'test-token-abc'; +const keyAuth = `${token}.${thumb}`; +const txtValue = crypto.createHash('sha256').update(keyAuth).digest('base64url'); +t('TXT 值是 43 字符 base64url', /^[A-Za-z0-9_-]{43}$/.test(txtValue), txtValue); +t('★ base64url 不是标准 base64(无 +/=)', !/[+/=]/.test(txtValue), txtValue); + +console.log('\n[5] PEM 输出格式'); +const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey); +const pemLines = Buffer.from(pkcs8).toString('base64').match(/.{1,64}/g); +const pem = `-----BEGIN PRIVATE KEY-----\n${pemLines.join('\n')}\n-----END PRIVATE KEY-----\n`; +t('私钥 PEM 有正确的头尾', pem.startsWith('-----BEGIN PRIVATE KEY-----') && pem.includes('-----END PRIVATE KEY-----'), ''); +t('PEM 每行不超过 64 字符', pemLines.every((l) => l.length <= 64), ''); +try { + const k = crypto.createPrivateKey(pem); + t('Node 能加载生成的 PEM 私钥', k.asymmetricKeyType === 'ec', k.asymmetricKeyType); +} catch (e) { + t('Node 能加载生成的 PEM 私钥', false, e.message); +} + +fs.rmSync(tmp, { recursive: true, force: true }); +console.log('\n' + '='.repeat(56)); +console.log(`通过 ${pass} / ${pass + fails.length}`); +if (fails.length) { console.log('\n失败项:'); for (const f of fails) console.log(' · ' + f); } +process.exit(fails.length ? 1 : 0); diff --git a/blog-admin/tools/selftest-deploy.mjs b/blog-admin/tools/selftest-deploy.mjs new file mode 100644 index 00000000..ac06f194 --- /dev/null +++ b/blog-admin/tools/selftest-deploy.mjs @@ -0,0 +1,150 @@ +/** + * 部署适配层离线自测 —— 不联网,只验两块纯算法: + * ① 1Panel 的 `md5("1panel" + apiKey + timestamp)` 签名 + * ② 多吉云 `HMAC-SHA1(secretKey, path+"\n"+body)` → hex 的签名 + * + * 这两块是**最容易静默出错**的地方:签名算错了,服务端只会回一句 + * 「签名错误」,看不出是哪一步错的。用 Node 的 crypto 独立复算一遍, + * 至少保证「算法本身」是对的。 + * + * 跑法:node tools/selftest-deploy.mjs + */ +import crypto from 'node:crypto'; + +let pass = 0; +const fails = []; +function t(name, cond, extra = '') { + if (cond) { + pass++; + console.log(' ✓ ' + name); + } else { + fails.push(name + (extra ? ' → ' + extra : '')); + console.log(' ✗ ' + name + (extra ? ' → ' + extra : '')); + } +} + +// ---- 复刻 deployer.ts 里的 md5(RFC 1321)---- +function md5(bytes) { + const S = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, + 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, + 10, 15, 21, + ]; + const K = new Uint32Array(64); + for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0; + + const len = bytes.length; + const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6); + withPad.set(bytes); + withPad[len] = 0x80; + const bitLen = len * 8; + const lo = bitLen >>> 0; + const hi = Math.floor(bitLen / 4294967296) >>> 0; + const dv = new DataView(withPad.buffer); + dv.setUint32(withPad.length - 8, lo, true); + dv.setUint32(withPad.length - 4, hi, true); + + let a0 = 0x67452301, + b0 = 0xefcdab89, + c0 = 0x98badcfe, + d0 = 0x10325476; + const rotl = (x, c) => ((x << c) | (x >>> (32 - c))) >>> 0; + + for (let off = 0; off < withPad.length; off += 64) { + const M = new Uint32Array(16); + for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true); + let A = a0, + B = b0, + C = c0, + D = d0; + for (let i = 0; i < 64; i++) { + let F, g; + if (i < 16) { + F = (B & C) | (~B & D); + g = i; + } else if (i < 32) { + F = (D & B) | (~D & C); + g = (5 * i + 1) % 16; + } else if (i < 48) { + F = B ^ C ^ D; + g = (3 * i + 5) % 16; + } else { + F = C ^ (B | ~D); + g = (7 * i) % 16; + } + F = (F + A + K[i] + M[g]) >>> 0; + A = D; + D = C; + C = B; + B = (B + rotl(F, S[i])) >>> 0; + } + a0 = (a0 + A) >>> 0; + b0 = (b0 + B) >>> 0; + c0 = (c0 + C) >>> 0; + d0 = (d0 + D) >>> 0; + } + return [a0, b0, c0, d0] + .map((x) => { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, x, true); + return [...b].map((v) => v.toString(16).padStart(2, '0')).join(''); + }) + .join(''); +} + +const enc = (s) => new TextEncoder().encode(s); + +console.log('\n[1] md5(1Panel 签名的核心)'); +const vectors = [ + ['', 'd41d8cd98f00b204e9800998ecf8427e'], + ['a', '0cc175b9c0f1b6a831c399e269772661'], + ['abc', '900150983cd24fb0d6963f7d28e17f72'], + ['message digest', 'f96b697d7cb7938d525a2f31aaf161d0'], + ['12345678901234567890123456789012345678901234567890123456789012345678901234567890', + '57edf4a22be3c955ac49da2e2107b67a'], +]; +for (const [input, want] of vectors) { + const got = md5(enc(input)); + t(`md5("${input.slice(0, 20)}${input.length > 20 ? '…' : ''}")`, got === want, `got ${got} want ${want}`); +} +// 长度跨过 55/56/64 边界(补位逻辑最容易在这里错) +t('md5 在 55 字节输入下正确', md5(enc('a'.repeat(55))) === 'ef1772b6dff9a122358552954ad0df65', md5(enc('a'.repeat(55)))); +t('md5 在 56 字节输入下正确', md5(enc('a'.repeat(56))) === '3b0c8ac703f828b04c6c197006d17218', md5(enc('a'.repeat(56)))); +t('md5 在 64 字节输入下正确', md5(enc('a'.repeat(64))) === '014842d480b571495a4a0363793f7367', md5(enc('a'.repeat(64)))); + +console.log('\n[2] 1Panel 签名串格式'); +const apiKey = 'test-api-key-1234'; +const ts = '1767225600'; +const mine = md5(enc(`1panel${apiKey}${ts}`)); +const ref = crypto.createHash('md5').update(`1panel${apiKey}${ts}`).digest('hex'); +t('自制 md5 与 Node crypto 一致', mine === ref, `${mine} vs ${ref}`); +t('签名是 32 位小写 hex', /^[0-9a-f]{32}$/.test(mine), mine); +t('★ 前缀必须是字面量 "1panel"', md5(enc(`1Panel${apiKey}${ts}`)) !== mine, '大小写不同应得到不同结果'); + +console.log('\n[3] 多吉云签名(HMAC-SHA1 → hex)'); +function dogeSign(secretKey, path, body) { + return crypto.createHmac('sha1', secretKey).update(`${path}\n${body}`).digest('hex'); +} +const sk = 'test-secret-key'; +const path = '/cdn/cert/upload.json'; +const body = '{"note":"usj.cc","cert":"-----BEGIN","private":"-----BEGIN"}'; +const sig = dogeSign(sk, path, body); +t('签名是 40 位小写 hex(SHA1)', /^[0-9a-f]{40}$/.test(sig), sig); +t('★ 用的必须是 SHA1 不是 SHA256', sig.length === 40, `len=${sig.length}`); +t('★ stringToSign 是 path + "\\n" + body', dogeSign(sk, path, body) === dogeSign(sk, path, body), ''); +t('body 变了签名必须变', dogeSign(sk, path, body + ' ') !== sig, 'trailing space 应改变签名'); +t('path 变了签名必须变', dogeSign(sk, '/cdn/cert/bind.json', body) !== sig, ''); + +console.log('\n[4] 多吉云 Authorization 头格式'); +const ak = 'AKIDtest1234567890'; +const authHeader = `TOKEN ${ak}:${sig}`; +t('形如 `TOKEN :`', /^TOKEN [^:]+:[0-9a-f]{40}$/.test(authHeader), authHeader.slice(0, 30) + '…'); +t('★ 分隔符是冒号不是空格', authHeader.includes(`${ak}:`), ''); + +console.log('\n' + '='.repeat(56)); +console.log(`通过 ${pass} / ${pass + fails.length}`); +if (fails.length) { + console.log('\n失败项:'); + for (const f of fails) console.log(' · ' + f); +} +process.exit(fails.length ? 1 : 0); diff --git a/blog-admin/wrangler.toml b/blog-admin/wrangler.toml index f078a67a..6b414137 100644 --- a/blog-admin/wrangler.toml +++ b/blog-admin/wrangler.toml @@ -132,12 +132,22 @@ custom_domain = true # --------------------------------------------------------------------------- # 定时任务(合并 rss-robot 的抓取): +# 0 * * * * RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源) # 17 3 * * * 评论 GC(限流/验证码清理 + healthz 缓存刷新) -# 0 0 * * * RSS 抓取批1(offset 0,35 个源) -# 0 12 * * * RSS 抓取批2(offset 35) +# 10 4 * * * 证书续期检查(探针查剩余天数,≤30 天才真正签发+部署) # --------------------------------------------------------------------------- [triggers] -crons = ["17 3 * * *", "0 * * * *"] +crons = ["17 3 * * *", "0 * * * *", "10 4 * * *"] + +# --------------------------------------------------------------------------- +# 资源上限 +# ★ CPU 时间:证书签发要跑多次加密(ECDSA 签名 / SHA-256)与 DER 编码, +# 虽然总耗时主要是等在网络上(不算 CPU),但给足余量避免 1102 错误。 +# cron 的默认上限就是 30s;HTTP 请求默认也是 30s(付费版可提到 5 分钟)。 +# 免费版会忽略这个字段(免费版硬顶 10ms,签发功能在免费版上不可用)。 +# --------------------------------------------------------------------------- +[limits] +cpu_ms = 60000 [observability] enabled = true