Files
blog/blog-admin/src/lib/certissue.ts
T
zqlit 43dbc8b75f feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环
证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。

新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts        ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
                     thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts    部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts   编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
                     → 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts      新增 POST /ssl/issue、GET /ssl/renew-check、
                     POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron    每日 04:10 自动续期检查;cpu_ms 提到 60s

与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。

实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
  cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory

测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
2026-10-06 16:59:37 +08:00

345 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 签发 / 续期编排 —— 证书管家的「执行」半边。
*
* 一次签发任务的完整链路:
* 读配置 → 建 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<AcmeAccount> {
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<IssueOutcome> {
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<IssueOutcome[]> {
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<Record<string, unknown> | 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<string, unknown>;
// 退路:扫一遍所有凭据找第一条 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<string, unknown>;
}
}
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);
}