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 干净
This commit is contained in:
1 parent
432cf5e398
commit
43dbc8b75f
14 files changed
+3010
-6
No files matched your search
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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 };
|
||||
Reference in new issue
Block a user