Files
blog/blog-admin/tools/livecheck-adapters.mjs
T

337 lines
15 KiB
JavaScript
Raw Normal View History

/**
* 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);