Files
blog/blog-admin/tools/livecheck-adapters.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

338 lines
15 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.
/**
* 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);