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:
zqlit committed 2026-10-06 16:59:37 +08:00
1 parent 432cf5e398
commit 43dbc8b75f
14 files changed
+3010 -6

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
/**
* ACME 客户端离线自测 —— 不联网,只验最容易错的两块:
* ① CSR 的 DER 编码(用 Node 的 openssl 交叉验证)
* ② JWS 的 ES256 签名与 JWK thumbprint(用 Node crypto 独立复算)
*
* 跑法:node tools/selftest-acme.mjs
*
* ★ 2026-10-06 修正两处:
* · `execFileSync` 在本机沙箱里**必抛 EBUSY**(同步 spawn 一律被杀),
* 改用 `execFile` + promisify 的异步版本 —— 同一个坑在
* editor-api/test/role-perm.mjs 里也踩过一次。
* · CSR 长度断言原写 >300,实测 EC P-256 的 CSR 只有 250 字节左右
* (RSA 才上千)。改成 >200 —— 断言要卡在「结构明显不对」而不是
* 「我以为应该多大」。
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
const pexec = promisify(execFile);
let pass = 0;
const fails = [];
function t(name, cond, extra = '') {
if (cond) { pass++; console.log(' ✓ ' + name); }
else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ✗ ' + name + (extra ? ' → ' + extra : '')); }
}
// ---- 复刻 acme.ts 里的 DER 编码器(保持一致才有意义)----
const enc = new TextEncoder();
const concat = (...a) => { const n = a.reduce((x, y) => x + y.length, 0); const o = new Uint8Array(n); let f = 0; for (const x of a) { o.set(x, f); f += x.length; } return o; };
function wrap(tag, c) {
const len = c.length; let lb;
if (len < 0x80) lb = new Uint8Array([len]);
else if (len < 0x100) lb = new Uint8Array([0x81, len]);
else if (len < 0x10000) lb = new Uint8Array([0x82, len >> 8, len & 0xff]);
else lb = new Uint8Array([0x83, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff]);
return concat(new Uint8Array([tag]), lb, c);
}
const seq = (...p) => wrap(0x30, concat(...p));
const setOf = (...p) => wrap(0x31, concat(...p));
const octetStr = (b) => wrap(0x04, b);
const bitStr = (b) => wrap(0x03, concat(new Uint8Array([0x00]), b));
const rawTag = (t, b) => wrap(t, b);
function oid(dotted) {
const parts = dotted.split('.').map(Number);
const body = [40 * parts[0] + parts[1]];
for (const v of parts.slice(2)) { const st = [v & 0x7f]; let x = v >> 7; while (x > 0) { st.unshift((x & 0x7f) | 0x80); x >>= 7; } body.push(...st); }
return wrap(0x06, new Uint8Array(body));
}
function intBytes(b) { return wrap(0x02, b[0] & 0x80 ? concat(new Uint8Array([0]), b) : b); }
const trimZ = (b) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; return b.slice(i); };
const b64u = (b) => Buffer.from(b).toString('base64url');
const b64uToBytes = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
// 用异步 execFile —— 本机(Windows + 沙箱)同步 spawn 一律 EBUSY
function openssl(args) {
return new Promise((resolve) => {
execFile('openssl', args, { encoding: 'utf8' }, (err, stdout, stderr) => {
resolve({ err, out: (stdout || '') + (stderr || '') });
});
});
}
async function makeCsr(kp, domains) {
const cn = domains[0];
const pub = await crypto.subtle.exportKey('jwk', kp.publicKey);
const x = b64uToBytes(pub.x), y = b64uToBytes(pub.y);
const spki = seq(seq(oid('1.2.840.10045.2.1'), oid('1.2.840.10045.3.1.7')), bitStr(concat(new Uint8Array([4]), x, y)));
const sanExt = seq(oid('2.5.29.17'), octetStr(seq(...domains.map((d) => rawTag(0x82, enc.encode(d))))));
// ★ 只套三层 seq:Attribute / Extensions / SAN 扩展本身。
// 多一层(setOf(seq(seq(sanExt))))会让 openssl 报
// `wrong tag ... Field=object, Type=X509_ATTRIBUTE`。
const extReq = rawTag(0xa0, seq(oid('1.2.840.113549.1.9.14'), setOf(seq(sanExt))));
const cri = seq(wrap(0x02, new Uint8Array([0])), seq(setOf(seq(oid('2.5.4.3'), rawTag(0x0c, enc.encode(cn))))), spki, extReq);
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, kp.privateKey, cri);
const r = trimZ(new Uint8Array(sig).slice(0, 32)), s = trimZ(new Uint8Array(sig).slice(32));
const derSig = seq(intBytes(r), intBytes(s));
return seq(cri, seq(oid('1.2.840.10045.4.3.2')), bitStr(derSig));
}
console.log('\n[1] CSR 生成与结构验证');
const kp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const domains = ['usj.cc', '*.usj.cc'];
const csr = await makeCsr(kp, domains);
const csrDer = Buffer.from(csr);
t('CSR 以 SEQUENCE 开头(0x30)', csrDer[0] === 0x30, 'got 0x' + csrDer[0].toString(16));
t('CSR 长度合理(>200 字节)', csrDer.length > 200, String(csrDer.length));
// 用 Node 自带的 crypto 交叉验证:能解析说明 DER 结构合法
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'acme-csr-'));
const csrPem = '-----BEGIN CERTIFICATE REQUEST-----\n' +
(csrDer.toString('base64').match(/.{1,64}/g) || []).join('\n') +
'\n-----END CERTIFICATE REQUEST-----\n';
const csrFile = path.join(tmp, 'test.csr');
fs.writeFileSync(csrFile, csrPem);
try {
const { stdout: out } = await pexec('openssl', ['req', '-in', csrFile, '-noout', '-text', '-verify'], {
encoding: 'utf8',
});
t('openssl 能解析并验签通过', out.includes('verify OK') || out.includes('Certificate Request'), '');
t('CSR 里含 CN=usj.cc', /CN\s*=\s*usj\.cc|commonName.*usj\.cc/s.test(out), '');
t('CSR 里含 SAN usj.cc', out.includes('usj.cc'), '');
t('CSR 里含通配符 *.usj.cc', out.includes('*.usj.cc'), '');
t('签名算法是 ecdsa-with-SHA256', /ecdsa-with-SHA256|id-ecPublicKey|prime256v1/i.test(out), '');
} catch (e) {
t('openssl 解析 CSR', false, (e.stderr || e.message || '').slice(0, 200));
}
console.log('\n[2] JWS / ES256 验证');
const accKp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const jwk = await crypto.subtle.exportKey('jwk', accKp.privateKey);
const protectedHeader = { alg: 'ES256', nonce: 'abc123', url: 'https://acme.example/new-account', jwk };
const payload = { termsOfServiceAgreed: true };
const p64 = Buffer.from(JSON.stringify(protectedHeader)).toString('base64url');
const pl64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, accKp.privateKey, enc.encode(p64 + '.' + pl64));
t('ES256 签名是 64 字节(raw r||s,不是 DER)', sig.byteLength === 64, String(sig.byteLength));
// 用 Node crypto 独立验签(JWK → KeyObject)
const pubKey = crypto.createPublicKey({ key: { ...jwk, d: undefined }, format: 'jwk' });
const ok = crypto.verify('sha256', Buffer.from(p64 + '.' + pl64), { key: pubKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig));
t('Node crypto 验签通过(ieee-p1363 = 原始 r||s)', ok === true, String(ok));
console.log('\n[3] JWK thumbprint(RFC 7638)');
const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
const thumb = crypto.createHash('sha256').update(canonical).digest('base64url');
t('thumbprint 是 43 字符的 base64url', /^[A-Za-z0-9_-]{43}$/.test(thumb), thumb);
t('字段顺序必须是 crv,kty,x,y', canonical.indexOf('crv') < canonical.indexOf('kty') && canonical.indexOf('kty') < canonical.indexOf('x'), canonical.slice(0, 40));
console.log('\n[4] key authorization(DNS-01)');
const token = 'test-token-abc';
const keyAuth = `${token}.${thumb}`;
const txtValue = crypto.createHash('sha256').update(keyAuth).digest('base64url');
t('TXT 值是 43 字符 base64url', /^[A-Za-z0-9_-]{43}$/.test(txtValue), txtValue);
t('★ base64url 不是标准 base64(无 +/=)', !/[+/=]/.test(txtValue), txtValue);
console.log('\n[5] PEM 输出格式');
const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
const pemLines = Buffer.from(pkcs8).toString('base64').match(/.{1,64}/g);
const pem = `-----BEGIN PRIVATE KEY-----\n${pemLines.join('\n')}\n-----END PRIVATE KEY-----\n`;
t('私钥 PEM 有正确的头尾', pem.startsWith('-----BEGIN PRIVATE KEY-----') && pem.includes('-----END PRIVATE KEY-----'), '');
t('PEM 每行不超过 64 字符', pemLines.every((l) => l.length <= 64), '');
try {
const k = crypto.createPrivateKey(pem);
t('Node 能加载生成的 PEM 私钥', k.asymmetricKeyType === 'ec', k.asymmetricKeyType);
} catch (e) {
t('Node 能加载生成的 PEM 私钥', false, e.message);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log('\n' + '='.repeat(56));
console.log(`通过 ${pass} / ${pass + fails.length}`);
if (fails.length) { console.log('\n失败项:'); for (const f of fails) console.log(' · ' + f); }
process.exit(fails.length ? 1 : 0);