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
@@ -1977,6 +1977,9 @@ async function viewSSL() {
|
||||
v.innerHTML = head('证书管家', 'CERT KEEPER',
|
||||
'域名 ' + ov.domains.length + ' 个 · 提醒阈值 ' + ov.notify.daysBefore + ' 天',
|
||||
'<button class="btn tiny" data-act="ssl-check"><span>立即检查</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-selfcheck"><span>环境自检</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-renew-check"><span>该续期了吗</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-issue-all"><span>签发/续期</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-notify"><span>发提醒邮件</span></button>' +
|
||||
'<button class="btn tiny" data-act="ssl-add-domain"><span>+ 域名</span></button>' +
|
||||
(ov.mailEnabled ? '' : '<span class="tag warn">邮件未配置</span>')) +
|
||||
@@ -2037,6 +2040,7 @@ function sslDomainTable(ov) {
|
||||
'<td>' + (d.deploy.length ? d.deploy.map((t) => '<span class="tag line">' + esc(t) + '</span>').join(' ') : '<span style="color:var(--muted)">—</span>') + '</td>' +
|
||||
'<td style="white-space:nowrap">' +
|
||||
'<button class="btn tiny" data-act="ssl-probe" data-host="' + esc(d.san.find((s) => !s.startsWith('*.')) || d.name) + '">实测</button> ' +
|
||||
'<button class="btn tiny" data-act="ssl-issue" data-domain="' + esc(d.name) + '">签发</button> ' +
|
||||
'<button class="btn tiny" data-act="ssl-import" data-domain="' + esc(d.name) + '">登记</button> ' +
|
||||
'<button class="btn tiny" data-act="ssl-edit-domain" data-domain="' + esc(d.name) + '">编辑</button>' +
|
||||
'</td>' +
|
||||
@@ -2284,6 +2288,34 @@ function sslImportModal(domain) {
|
||||
}
|
||||
|
||||
/** 实测结果弹窗 —— 结构化展示,别再让用户去 openssl */
|
||||
function sslSelfCheckModal(r) {
|
||||
const items = r.items || [];
|
||||
const bad = items.filter((x) => !x.ok);
|
||||
const kindLabel = { ca: 'CA', dns: 'DNS', deploy: '部署', target: '域名' };
|
||||
const rows = items.map((x) =>
|
||||
'<tr>' +
|
||||
'<td>' + (x.ok ? '<span class="tag ok">通过</span>' : '<span class="tag dang">失败</span>') + '</td>' +
|
||||
'<td><b>' + esc(x.name) + '</b><div class="lk">' + esc(kindLabel[x.kind] || x.kind) + '</div></td>' +
|
||||
'<td>' + esc(x.detail) +
|
||||
(x.hint ? '<div class="lk" style="color:var(--warning)">建议:' + esc(x.hint) + '</div>' : '') +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
|
||||
modal('环境自检',
|
||||
(bad.length
|
||||
? '<div class="alert" style="background:var(--danger-soft);border-color:var(--danger-line);color:var(--danger)">' +
|
||||
'<b>' + bad.length + '</b> / ' + items.length + ' 项有问题 —— 这些就是「续期那天才会炸」的地方,建议现在修掉。</div>'
|
||||
: '<div class="alert" style="background:var(--ok-soft);border-color:var(--accent-line);color:var(--success)">' +
|
||||
'全部 ' + items.length + ' 项通过 —— 凭据齐备,续期链路可用。</div>') +
|
||||
'<div class="tblwrap" style="margin-top:12px"><table><thead><tr>' +
|
||||
'<th style="width:64px"></th><th>项目</th><th>结果 / 建议</th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table></div>' +
|
||||
'<p style="color:var(--muted);font-size:12px;margin-top:12px">自检<b>不会签发证书、不会写 DNS、不会改站点</b>,' +
|
||||
'只做只读探测(拉 CA 目录 / 读 TXT / 列站点)。所以它可以随时安全地跑。</p>' +
|
||||
'<div style="text-align:right;margin-top:16px"><button class="btn" data-act="modal-close"><span>关闭</span></button></div>');
|
||||
}
|
||||
|
||||
function sslProbeModal(host, r) {
|
||||
const dl = (ms) => ms == null ? '—' : new Date(ms + 8 * 3600e3).toISOString().slice(0, 19).replace('T', ' ');
|
||||
if (!r.ok) {
|
||||
@@ -2527,6 +2559,61 @@ document.addEventListener('click', async (e) => {
|
||||
delete el.dataset.busy;
|
||||
return;
|
||||
}
|
||||
// 环境自检:不签发、不写 DNS,只验证凭据「连得上、认得对」
|
||||
if (act === 'ssl-selfcheck') {
|
||||
el.dataset.busy = '1';
|
||||
el.innerHTML = '<span>自检中…</span>';
|
||||
try {
|
||||
const r = await sslApi('/selfcheck', { method: 'POST' });
|
||||
sslSelfCheckModal(r);
|
||||
} catch (ex) { toast('✕ ' + ex.message, true); }
|
||||
el.innerHTML = '<span>环境自检</span>';
|
||||
delete el.dataset.busy;
|
||||
return;
|
||||
}
|
||||
|
||||
// 续期检查(只读,秒回):看哪些域名到了该续的天数
|
||||
if (act === 'ssl-renew-check') {
|
||||
el.dataset.busy = '1';
|
||||
try {
|
||||
const r = await sslApi('/renew-check');
|
||||
const need = (r.items || []).filter((i) => i.action !== 'ok');
|
||||
if (!need.length) toast('✓ 全部域名都还不需要续期(阈值 ' + r.threshold + ' 天)');
|
||||
else toast('⚠ ' + need.map((i) => i.domain + '(' + (i.daysLeft == null ? '无记录' : i.daysLeft + ' 天') + ')').join('、'), true);
|
||||
state.ssl.renewCheck = r;
|
||||
await viewSSL();
|
||||
} catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; }
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 真正签发(慢,1~3 分钟/域名):必须明确告知用户「正在跑,别关页面」
|
||||
if (act === 'ssl-issue' || act === 'ssl-issue-all') {
|
||||
const domain = act === 'ssl-issue' ? el.dataset.domain : '';
|
||||
const label = domain || '全部启用的域名';
|
||||
confirmBox('签发证书:' + label,
|
||||
'会真的向 CA 申请一张新证书,然后部署到配置里的目标(多吉云 / 1Panel)。' +
|
||||
'每个域名大约 1~3 分钟(要等 DNS 记录生效)。期间请不要关闭页面。确定开始?',
|
||||
async () => {
|
||||
el.dataset.busy = '1';
|
||||
const old = el.innerHTML;
|
||||
el.innerHTML = '<span>签发中…</span>';
|
||||
try {
|
||||
const r = await sslApi('/issue', { method: 'POST', body: { domain, force: true } });
|
||||
const list = r.results || [];
|
||||
const bad = list.filter((x) => !x.ok);
|
||||
if (!list.length) toast('没有可签发的域名');
|
||||
else if (bad.length) toast('⚠ ' + bad.length + '/' + list.length + ' 失败:' + bad.map((x) => x.domain + ' ' + x.reason).join(';'), true);
|
||||
else toast('✓ ' + list.map((x) => x.domain + ' ' + x.reason).join(';'));
|
||||
await viewSSL();
|
||||
} catch (ex) {
|
||||
toast('✕ ' + ex.message, true);
|
||||
el.innerHTML = old;
|
||||
delete el.dataset.busy;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (act === 'ssl-notify-save') {
|
||||
const emails = $('#sslNotifyEmails').value.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||
const days = Number($('#sslNotifyDays').value);
|
||||
|
||||
Reference in new issue
Block a user