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
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 部署适配层离线自测 —— 不联网,只验两块纯算法:
|
||||
* ① 1Panel 的 `md5("1panel" + apiKey + timestamp)` 签名
|
||||
* ② 多吉云 `HMAC-SHA1(secretKey, path+"\n"+body)` → hex 的签名
|
||||
*
|
||||
* 这两块是**最容易静默出错**的地方:签名算错了,服务端只会回一句
|
||||
* 「签名错误」,看不出是哪一步错的。用 Node 的 crypto 独立复算一遍,
|
||||
* 至少保证「算法本身」是对的。
|
||||
*
|
||||
* 跑法:node tools/selftest-deploy.mjs
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
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 : ''));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 复刻 deployer.ts 里的 md5(RFC 1321)----
|
||||
function md5(bytes) {
|
||||
const S = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
|
||||
20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6,
|
||||
10, 15, 21,
|
||||
];
|
||||
const K = new Uint32Array(64);
|
||||
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0;
|
||||
|
||||
const len = bytes.length;
|
||||
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
|
||||
withPad.set(bytes);
|
||||
withPad[len] = 0x80;
|
||||
const bitLen = len * 8;
|
||||
const lo = bitLen >>> 0;
|
||||
const hi = Math.floor(bitLen / 4294967296) >>> 0;
|
||||
const dv = new DataView(withPad.buffer);
|
||||
dv.setUint32(withPad.length - 8, lo, true);
|
||||
dv.setUint32(withPad.length - 4, hi, true);
|
||||
|
||||
let a0 = 0x67452301,
|
||||
b0 = 0xefcdab89,
|
||||
c0 = 0x98badcfe,
|
||||
d0 = 0x10325476;
|
||||
const rotl = (x, c) => ((x << c) | (x >>> (32 - c))) >>> 0;
|
||||
|
||||
for (let off = 0; off < withPad.length; off += 64) {
|
||||
const M = new Uint32Array(16);
|
||||
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true);
|
||||
let A = a0,
|
||||
B = b0,
|
||||
C = c0,
|
||||
D = d0;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let F, g;
|
||||
if (i < 16) {
|
||||
F = (B & C) | (~B & D);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
F = (D & B) | (~D & C);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
F = B ^ C ^ D;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
F = C ^ (B | ~D);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
F = (F + A + K[i] + M[g]) >>> 0;
|
||||
A = D;
|
||||
D = C;
|
||||
C = B;
|
||||
B = (B + rotl(F, S[i])) >>> 0;
|
||||
}
|
||||
a0 = (a0 + A) >>> 0;
|
||||
b0 = (b0 + B) >>> 0;
|
||||
c0 = (c0 + C) >>> 0;
|
||||
d0 = (d0 + D) >>> 0;
|
||||
}
|
||||
return [a0, b0, c0, d0]
|
||||
.map((x) => {
|
||||
const b = new Uint8Array(4);
|
||||
new DataView(b.buffer).setUint32(0, x, true);
|
||||
return [...b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
const enc = (s) => new TextEncoder().encode(s);
|
||||
|
||||
console.log('\n[1] md5(1Panel 签名的核心)');
|
||||
const vectors = [
|
||||
['', 'd41d8cd98f00b204e9800998ecf8427e'],
|
||||
['a', '0cc175b9c0f1b6a831c399e269772661'],
|
||||
['abc', '900150983cd24fb0d6963f7d28e17f72'],
|
||||
['message digest', 'f96b697d7cb7938d525a2f31aaf161d0'],
|
||||
['12345678901234567890123456789012345678901234567890123456789012345678901234567890',
|
||||
'57edf4a22be3c955ac49da2e2107b67a'],
|
||||
];
|
||||
for (const [input, want] of vectors) {
|
||||
const got = md5(enc(input));
|
||||
t(`md5("${input.slice(0, 20)}${input.length > 20 ? '…' : ''}")`, got === want, `got ${got} want ${want}`);
|
||||
}
|
||||
// 长度跨过 55/56/64 边界(补位逻辑最容易在这里错)
|
||||
t('md5 在 55 字节输入下正确', md5(enc('a'.repeat(55))) === 'ef1772b6dff9a122358552954ad0df65', md5(enc('a'.repeat(55))));
|
||||
t('md5 在 56 字节输入下正确', md5(enc('a'.repeat(56))) === '3b0c8ac703f828b04c6c197006d17218', md5(enc('a'.repeat(56))));
|
||||
t('md5 在 64 字节输入下正确', md5(enc('a'.repeat(64))) === '014842d480b571495a4a0363793f7367', md5(enc('a'.repeat(64))));
|
||||
|
||||
console.log('\n[2] 1Panel 签名串格式');
|
||||
const apiKey = 'test-api-key-1234';
|
||||
const ts = '1767225600';
|
||||
const mine = md5(enc(`1panel${apiKey}${ts}`));
|
||||
const ref = crypto.createHash('md5').update(`1panel${apiKey}${ts}`).digest('hex');
|
||||
t('自制 md5 与 Node crypto 一致', mine === ref, `${mine} vs ${ref}`);
|
||||
t('签名是 32 位小写 hex', /^[0-9a-f]{32}$/.test(mine), mine);
|
||||
t('★ 前缀必须是字面量 "1panel"', md5(enc(`1Panel${apiKey}${ts}`)) !== mine, '大小写不同应得到不同结果');
|
||||
|
||||
console.log('\n[3] 多吉云签名(HMAC-SHA1 → hex)');
|
||||
function dogeSign(secretKey, path, body) {
|
||||
return crypto.createHmac('sha1', secretKey).update(`${path}\n${body}`).digest('hex');
|
||||
}
|
||||
const sk = 'test-secret-key';
|
||||
const path = '/cdn/cert/upload.json';
|
||||
const body = '{"note":"usj.cc","cert":"-----BEGIN","private":"-----BEGIN"}';
|
||||
const sig = dogeSign(sk, path, body);
|
||||
t('签名是 40 位小写 hex(SHA1)', /^[0-9a-f]{40}$/.test(sig), sig);
|
||||
t('★ 用的必须是 SHA1 不是 SHA256', sig.length === 40, `len=${sig.length}`);
|
||||
t('★ stringToSign 是 path + "\\n" + body', dogeSign(sk, path, body) === dogeSign(sk, path, body), '');
|
||||
t('body 变了签名必须变', dogeSign(sk, path, body + ' ') !== sig, 'trailing space 应改变签名');
|
||||
t('path 变了签名必须变', dogeSign(sk, '/cdn/cert/bind.json', body) !== sig, '');
|
||||
|
||||
console.log('\n[4] 多吉云 Authorization 头格式');
|
||||
const ak = 'AKIDtest1234567890';
|
||||
const authHeader = `TOKEN ${ak}:${sig}`;
|
||||
t('形如 `TOKEN <ak>:<sig>`', /^TOKEN [^:]+:[0-9a-f]{40}$/.test(authHeader), authHeader.slice(0, 30) + '…');
|
||||
t('★ 分隔符是冒号不是空格', authHeader.includes(`${ak}:`), '');
|
||||
|
||||
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);
|
||||
Reference in new issue
Block a user