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,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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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}」不在凭据清单里`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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