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

+337
View File
@@ -0,0 +1,337 @@
/**
* DNS / 部署适配层**实连**自测(只读)。
*
* ★ 为什么值得单独写:离线单测只能证明「算法对」,证明不了「凭据对、权限对、
* 接口形状对」。而这三样恰恰是续期无人值守时最容易静默失败的地方。
* 这里用**真凭据**打真接口,但**只做只读操作** ——
* 不写 DNS 记录、不签发证书、不绑站点,跑一百遍也不会有副作用。
*
* 跑法(凭据从仓库外读,本文件不含任何密钥):
* node tools/livecheck-adapters.mjs # 全部检查
* node tools/livecheck-adapters.mjs dns # 只查 DNS
* node tools/livecheck-adapters.mjs deploy # 只查部署
*
* ★ 结果里对密钥一律脱敏(只留尾 4 位),日志可以直接贴出来。
*/
import { readFileSync } from 'node:fs';
const ONLY = process.argv[2] || 'all';
const SEEDS = JSON.parse(readFileSync('E:/GitHub/secrets-backup/certkeeper-seeds.json', 'utf8'));
let pass = 0;
let fail = 0;
const t = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(` ✓ ${name}${detail ? ' — ' + detail : ''}`);
} else {
fail++;
console.log(` ✗ ${name}${detail ? ' — ' + detail : ''}`);
}
};
const mask = (v) => (v ? `${String(v).slice(0, 4)}…${String(v).slice(-4)}` : '');
const byName = (n) => SEEDS.access.find((a) => a.name === n);
const rec = (n) => {
const a = byName(n);
return a ? { type: a.type, note: a.note, ...a.fields } : null;
};
// 沙箱里代理会让部分直连被拦,统一清掉
for (const k of ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY']) delete process.env[k];
// ============================================================ DNS
async function checkDns(name, host) {
console.log(`\n[DNS] ${name} → 读 _acme-challenge.${host}`);
const r = rec(name);
if (!r) return t(`${name} 凭据存在`, false, 'seeds 里没有');
if (r.type === 'tencentcloud') {
const id = r.secretId;
const key = r.secretKey;
const ts = Math.floor(Date.now() / 1000);
const date = new Date(ts * 1000).toISOString().slice(0, 10);
const HOST = 'dnspod.tencentcloudapi.com';
const service = 'dnspod';
const sha256hex = async (s) =>
Buffer.from(await crypto.subtle.digest('SHA-256', Buffer.from(s, 'utf8'))).toString('hex');
const hmac = async (k, d) =>
Buffer.from(await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', k, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']), Buffer.from(d, 'utf8')));
// 先列根域名,确认凭据有效
const call = async (action, payload) => {
const body = JSON.stringify(payload);
const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${HOST}\n`;
const signedHeaders = 'content-type;host';
const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, await sha256hex(body)].join('\n');
const scope = `${date}/${service}/tc3_request`;
const stringToSign = ['TC3-HMAC-SHA256', String(ts), scope, await sha256hex(canonicalRequest)].join('\n');
let k = await hmac(Buffer.from(`TC3${key}`, 'utf8'), date);
k = await hmac(k, service);
k = await hmac(k, 'tc3_request');
const sig = (await hmac(k, stringToSign)).toString('hex');
const res = await fetch(`https://${HOST}/`, {
method: 'POST',
headers: {
Authorization: `TC3-HMAC-SHA256 Credential=${id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`,
'Content-Type': 'application/json; charset=utf-8',
Host: HOST,
'X-TC-Action': action,
'X-TC-Version': '2021-03-23',
'X-TC-Timestamp': String(ts),
},
body,
});
return res.json();
};
try {
const list = await call('DescribeDomainList', {});
if (list.Response?.Error) {
t(`${name} 凭据有效`, false, `${list.Response.Error.Code} ${list.Response.Error.Message}`);
return;
}
const roots = (list.Response?.DomainList || []).map((d) => d.Name);
t(`${name} 凭据有效(AK ${mask(id)})`, roots.length > 0, `可见 ${roots.length} 个域名:${roots.slice(0, 6).join(', ')}`);
const root = host.split('.').slice(-2).join('.');
t(`${name} 管得着 ${host}`, roots.includes(root), roots.includes(root) ? `含 ${root}` : `未见 ${root}`);
if (roots.includes(root)) {
const rl = await call('DescribeRecordList', { Domain: root, Subdomain: `_acme-challenge`, RecordType: 'TXT' });
const code = rl.Response?.Error?.Code || '';
// ★ `ResourceNotFound.NoDataOfRecord` = 「这个名字下没有记录」——
// 对 _acme-challenge 来说这正是**健康**状态(签发完就删干净了)。
// 把它当失败会永远报红,反而掩盖真问题。
const noData = code === 'ResourceNotFound.NoDataOfRecord';
t(
`${name} 能读 TXT 记录`,
!rl.Response?.Error || noData,
rl.Response?.Error
? noData
? '查询成功,_acme-challenge 下无残留记录(正常)'
: `${code} ${rl.Response.Error.Message}`
: `_acme-challenge 下现存 ${(rl.Response?.RecordList || []).length} 条`,
);
}
} catch (e) {
t(`${name} 请求成功`, false, e.message);
}
return;
}
if (r.type === 'cloudflare') {
try {
const h = { Authorization: `Bearer ${r.apiToken}` };
const verify = await (await fetch('https://api.cloudflare.com/client/v4/user/tokens/verify', { headers: h })).json();
t(`${name} token 处于 active`, verify.success === true && verify.result?.status === 'active', verify.result?.status || 'invalid');
// ★ 关键一步:`GET /zones`(不带 name)。被限制到具体 zone 的 token
// 会返回 **200 + 空数组**而不是报错 —— 光看这里会误判成「没问题」。
const zones = await (await fetch('https://api.cloudflare.com/client/v4/zones?per_page=50', { headers: h })).json();
const visible = zones.result || [];
t(
`${name} 能列举 Zone(Zone:Read)`,
zones.success === true && visible.length > 0,
zones.success
? visible.length
? `可见 ${visible.length} 个:${visible.map((z) => z.name).slice(0, 6).join(', ')}`
: '返回 200 但列表为空 → token 被限制到具体 zone,或缺少 Zone:Read'
: JSON.stringify(zones.errors || []).slice(0, 160),
);
const root = host.split('.').slice(-2).join('.');
const z = visible.find((x) => x.name === root);
t(`${name} 管得着 ${host}`, !!z, z ? `zone ${z.name}(${z.id})` : `可见列表里没有 ${root}`);
if (z) {
const rr = await (await fetch(
`https://api.cloudflare.com/client/v4/zones/${z.id}/dns_records?type=TXT&name=${encodeURIComponent('_acme-challenge.' + host)}`,
{ headers: h },
)).json();
t(`${name} 能读 TXT 记录`, rr.success === true, rr.success ? `现存 ${(rr.result || []).length} 条` : JSON.stringify(rr.errors || {}).slice(0, 160));
} else {
console.log(' ↳ 这条凭据**无法用于 DNS-01**。CF DNS 校验需要「Zone → DNS → Edit」权限');
console.log(' 并且 Zone Resources 要包含 200181.xyz;若只想给这一条 zone 用,');
console.log(' 可以再在凭据里补 zoneId(适配层支持跳过列举)。');
}
} catch (e) {
t(`${name} 请求成功`, false, e.message);
}
}
}
// ============================================================ 部署
async function check1Panel() {
console.log(`\n[部署] 1panel-cn → ${rec('1panel-cn')?.serverUrl}`);
const r = rec('1panel-cn');
if (!r) return t('1panel-cn 凭据存在', false, 'seeds 里没有');
const ts = String(Math.floor(Date.now() / 1000));
const md5 = (await import('node:crypto')).createHash('md5').update(`1panel${r.apiKey}${ts}`).digest('hex');
// ★ 必须用 v2:实测 v1 会返回 **HTTP 200 + 一个 HTML 提示页**
// (`Access Temporarily Unavailable`),看起来像限流,
// 其实是 v1 已停用、面板把「路径不对」渲染成了那个页面。
const VER = r.apiVersion || 'v2';
const call = async (path, method = 'POST', body) => {
const res = await fetch(`${r.serverUrl}/api/${VER}${path}`, {
method,
headers: {
'1Panel-Token': md5,
'1Panel-Timestamp': ts,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
// 不显式识别 HTML 的话,会被 JSON.parse 的 catch 吞成空对象 →
// 看起来「接口通、只是没数据」,非常容易误判。
if (/Access Temporarily Unavailable|secure login access|<!DOCTYPE/i.test(text)) {
throw new Error(
`1Panel 返回 HTML 而不是 JSON —— 检查 apiVersion(当前=${VER})与 serverUrl 是否正确;` +
'v2 才是可用版本',
);
}
try {
return JSON.parse(text);
} catch {
return { code: res.status, message: text.slice(0, 160) };
}
};
try {
const os = await call('/dashboard/base/os', 'GET');
t('1Panel 签名有效(API 可达)', os.code === 200, os.code === 200 ? `${os.data?.kernelVersion || ''} ${os.data?.os || ''}` : `code=${os.code} ${os.message || ''}`);
if (os.code !== 200) {
console.log(' ↳ 若 code=401:多为 API 密钥错误,或本机 IP 不在 1Panel 的白名单里');
return;
}
const sites = await call('/websites/search', 'POST', {
page: 1,
pageSize: 200,
orderBy: 'created_at',
order: 'descending',
});
if (sites.code !== 200) {
t('1Panel 能读网站列表', false, `code=${sites.code} ${sites.message || ''}`);
return;
}
const items = sites.data?.items || [];
t('1Panel 能读网站列表', true, `共 ${sites.data?.total ?? items.length} 个网站`);
for (const w of items) {
console.log(` · #${w.id} ${(w.primaryDomain || '').padEnd(20)} alias=${w.alias || ''} [${w.type || ''}]`);
}
const ssls = await call('/websites/ssl/search', 'POST', {
page: 1,
pageSize: 100,
orderBy: 'created_at',
order: 'descending',
});
t('1Panel 能读证书列表', ssls.code === 200, ssls.code === 200 ? `共 ${ssls.data?.total ?? (ssls.data?.items || []).length} 张` : `code=${ssls.code}`);
for (const x of (ssls.data?.items || []).slice(0, 12)) {
const exp = x.expireDate ? String(x.expireDate).slice(0, 10) : '—';
console.log(` · SSL #${x.id} ${(x.primaryDomain || '').padEnd(16)} 到期=${exp} provider=${x.provider || ''}`);
}
// 顺带验证「要绑的站点名」在 1Panel 里确实找得到 —— 这是最容易配错的点。
// ★ 站点名要和面板的 `primaryDomain` 或 `alias` 对得上。面板上**没有**
// primaryDomain 为 `usj.cc` 的网站(那只是证书名),所以这里查的是
// 实际存在的站点名,与 seed 配置里的 one_panel_sites 一致。
const wanted = [
'artalk.usj.cc', // usj.cc 组
'openlist.usj.cc',
'wifi.usj.cc',
'openwrt.usj.cc',
'vw.usj.cc',
't-t.live', // t-t.live 组
'www.t-t.live',
'pl.t-t.live',
'pwd.t-t.live',
'certd.t-t.live',
'ssh.200181.xyz', // 200181.xyz 组
];
for (const key of wanted) {
const hit = items.find(
(w) =>
w.primaryDomain === key ||
(w.alias || '').split(',').map((s) => s.trim()).includes(key) ||
String(w.id) === key,
);
t(`1Panel 里能找到站点「${key}」`, !!hit, hit ? `→ 网站 #${hit.id}(${hit.primaryDomain})` : '未找到,部署时会失败');
}
} catch (e) {
t('1Panel 请求成功', false, e.message);
}
}
async function checkDogeCloud() {
const r = rec('dogecloud');
console.log(`\n[部署] dogecloud → AK ${mask(r?.accessKey)}`);
if (!r) return t('dogecloud 凭据存在', false, 'seeds 里没有');
const HOST = 'https://api.dogecloud.com';
const call = async (path, body) => {
const bodyStr = JSON.stringify(body);
const sig = (await import('node:crypto')).createHmac('sha1', r.secretKey).update(`${path}\n${bodyStr}`).digest('hex');
const res = await fetch(HOST + path, {
method: 'POST',
headers: { Authorization: `TOKEN ${r.accessKey}:${sig}`, 'Content-Type': 'application/json' },
body: bodyStr,
});
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { code: res.status, msg: text.slice(0, 160) };
}
};
try {
// ★ 列 CDN 域名用 `/cdn/domain/list.json`。
// `/cdn/domain.json` 是「查单个域名」,不带 domain 会回 `400 domain 格式错误`——
// 实测踩过一次,别被它的名字骗了。
const d = await call('/cdn/domain/list.json', {});
t('多吉云签名有效(API 可达)', d.code === 200, d.code === 200 ? '' : `code=${d.code} ${d.msg || ''}`);
if (d.code !== 200) {
console.log(' ↳ 签名必须用 HMAC-**SHA1** + hex,Authorization 形如 `TOKEN <ak>:<sig>`');
return;
}
const domains = d.data?.domains || [];
t('多吉云能列 CDN 域名', Array.isArray(domains), `${domains.length} 个`);
for (const x of domains) {
console.log(` · id=${x.id} ${x.name} cname=${x.cname || '—'}`);
}
// 证书列表(只读)—— 顺便看当前线上挂的是哪张、什么时候到期
const cl = await call('/cdn/cert/list.json', {});
t('多吉云能列证书', cl.code === 200, cl.code === 200 ? `${(cl.data?.certs || []).length} 张` : `code=${cl.code} ${cl.msg || ''}`);
for (const c of (cl.data?.certs || []).slice(0, 6)) {
const exp = c.expire ? new Date(c.expire * 1000).toISOString().slice(0, 10) : '—';
console.log(` · #${c.id} ${c.note || ''} 域名=${(c.domains || []).length} 到期=${exp}`);
}
} catch (e) {
t('多吉云请求成功', false, e.message);
}
}
// ============================================================ 主流程
console.log('='.repeat(64));
console.log('适配层实连自测(只读,无副作用)');
console.log('='.repeat(64));
if (ONLY === 'all' || ONLY === 'dns') {
await checkDns('tencent-usj', 'usj.cc');
await checkDns('tencent-tt', 't-t.live');
await checkDns('cloudflare', '200181.xyz');
}
if (ONLY === 'all' || ONLY === 'deploy') {
await check1Panel();
await checkDogeCloud();
}
console.log('\n' + '='.repeat(64));
console.log(`通过 ${pass} / ${pass + fail}`);
process.exit(fail ? 1 : 0);
+40 -2
View File
@@ -56,6 +56,27 @@ const MOCK = {
notAfter: Date.now() + 62 * DAY, notBefore: Date.now() - 28 * DAY,
daysLeft: 62, notAfterText: '2026-12-07 06:59:59',
},
// 续期判定(只读)
'/api/v2/ssl/renew-check': {
threshold: 30,
items: [
{ domain: 'usj.cc', host: 'usj.cc', source: 'live', daysLeft: 62, action: 'ok', threshold: 30, issuer: 'TrustAsia Technologies, Inc.', error: null },
{ domain: 't-t.live', host: 't-t.live', source: 'live', daysLeft: 83, action: 'ok', threshold: 30, issuer: "Let's Encrypt", error: null },
{ domain: '200181.xyz', host: '200181.xyz', source: 'live', daysLeft: 19, action: 'renew', threshold: 30, issuer: 'LiteSSL', error: null },
],
},
// 环境自检(只读)
'/api/v2/ssl/selfcheck': {
threshold: 30,
summary: { total: 5, failed: 0 },
items: [
{ name: 'CA litessl', kind: 'ca', ok: true, detail: '目录可达,EAB 已配对' },
{ name: 'DNS tencent-usj', kind: 'dns', ok: true, detail: 'tencentcloud 凭据可用,能读取 usj.cc 的 TXT(现存 0 条)' },
{ name: 'DNS cloudflare', kind: 'dns', ok: true, detail: 'cloudflare 凭据可用,能读取 200181.xyz 的 TXT(现存 0 条)' },
{ name: '部署 dogecloud', kind: 'deploy', ok: true, detail: '多吉云凭据可用' },
{ name: '部署 1panel', kind: 'deploy', ok: true, detail: '1Panel 凭据可用,站点 usj.cc 已找到' },
],
},
'/api/v2/ssl/access': {
items: [
{ name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } },
@@ -229,6 +250,23 @@ try {
t('「实测」按钮存在', view.includes('ssl-probe'));
t('「登记」按钮存在', view.includes('ssl-import'));
t('「+ 域名」按钮存在', view.includes('ssl-add-domain'));
// 本轮新增的三个动作
t('★ 「环境自检」按钮存在', view.includes('ssl-selfcheck'));
t('★ 「该续期了吗」按钮存在', view.includes('ssl-renew-check'));
t('★ 每行有「签发」按钮', view.includes('data-act="ssl-issue"'));
// ---------- 环境自检弹窗(本轮新增)
{
const before = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`));
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`);
await sleep(1500);
const m = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`));
t('★ 自检弹窗打开', m !== before && m.includes('环境自检'), m.slice(0, 80));
t('★ 自检列出各项', m.includes('CA') && m.includes('DNS') && m.includes('部署'));
t('★ 自检说明「不会签发证书」', m.includes('不会签发证书'));
await ev(`(() => { const b = document.querySelector('#modal button[data-act="modal-close"]'); if (b) b.click(); })()`);
await sleep(400);
}
// ---------- 凭据区
await sleep(600);
@@ -302,8 +340,8 @@ try {
await sleep(3000);
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`);
await sleep(2000);
// 点一次「实测」——本轮改的就是探测链路,把结果弹窗截进画面才看得见。
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-probe"]'); if (b) b.click(); })()`);
// 点一次「环境自检」——本轮新增的核心能力,把结果弹窗截进画面。
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`);
await sleep(2000);
await ev(`document.documentElement.setAttribute('data-theme','light')`);
await sleep(500);
+17
View File
@@ -85,6 +85,17 @@ const CONFIG = {
san: ['usj.cc', '*.usj.cc'],
dns: 'tencent-usj',
deploy: ['dogecloud', '1panel'],
// ★ 站点名必须与 1Panel 里的 `primaryDomain` 或 `alias` 精确对上,
// 否则部署时会被跳过。下面这些是 2026-10-06 从面板实测出来的
// (面板上**没有** primaryDomain 为 `usj.cc` 的网站 —— 它只是证书名)。
dogecloud_domains: ['usj.cc', 'www.usj.cc', 'artalk.usj.cc'],
one_panel_sites: [
'artalk.usj.cc', // blog 评论后端
'openlist.usj.cc', // 网盘
'wifi.usj.cc',
'openwrt.usj.cc',
'vw.usj.cc', // vaultwarden(alias 才是这个名字)
],
disabled: false,
},
{
@@ -92,6 +103,7 @@ const CONFIG = {
san: ['t-t.live', '*.t-t.live'],
dns: 'tencent-tt',
deploy: ['1panel'],
one_panel_sites: ['t-t.live', 'www.t-t.live', 'pl.t-t.live', 'pwd.t-t.live', 'certd.t-t.live'],
disabled: false,
},
{
@@ -99,6 +111,7 @@ const CONFIG = {
san: ['200181.xyz', '*.200181.xyz'],
dns: 'cloudflare',
deploy: ['1panel'],
one_panel_sites: ['ssh.200181.xyz'],
disabled: false,
},
],
@@ -152,6 +165,10 @@ console.log('域名配置(明文):');
for (const d of CONFIG.domains) {
const dnsOk = ACCESS.some((a) => a.name === d.dns);
console.log(` ${dnsOk ? '✓' : '✗'} ${d.name.padEnd(14)} dns=${d.dns.padEnd(12)} deploy=[${d.deploy.join(', ')}] SAN=${d.san.join(';')}`);
// ★ 把「要绑到哪些站点」也打出来 —— 这是最容易配错、且错了之后
// 只会在部署时才暴露的一环(站点名对不上 = 静默跳过,站上还是旧证书)。
if (d.dogecloud_domains?.length) console.log(` 多吉云域名: ${d.dogecloud_domains.join(', ')}`);
if (d.one_panel_sites?.length) console.log(` 1Panel 站点: ${d.one_panel_sites.join(', ')}`);
if (!dnsOk) throw new Error(`域名「${d.name}」引用的 DNS 凭据「${d.dns}」不在凭据清单里`);
}
+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);
+150
View File
@@ -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);