337 lines
15 KiB
JavaScript
337 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);
|