Files
blog/blog-admin/src/lib/certissue.ts
T

344 lines
13 KiB
TypeScript
Raw Normal View History

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