Files
blog/blog-admin/tools/selftest-deploy.mjs
T
zqlit 43dbc8b75f 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 干净
2026-10-06 16:59:37 +08:00

151 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 部署适配层离线自测 —— 不联网,只验两块纯算法:
* ① 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);