/** * 签发 / 续期编排 —— 证书管家的「执行」半边。 * * 一次签发任务的完整链路: * 读配置 → 建 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); }