证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 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 干净
338 lines
15 KiB
JavaScript
338 lines
15 KiB
JavaScript
/**
|
||
* 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);
|