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