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