Files
blog/blog-admin/tools/probe-ssl-ui.mjs
T
zqlit 432cf5e398 feat: 证书探测改走国内机真 Node —— Workers 拿不到证书正文的兜底方案
根因:Workers 上 cloudflare:sockets 没有 getPeerCertificate,
node:tls 的同名方法是桩函数(调用即抛 not implemented)。

- editor-api 新增 /ssl-probe 本地端点(真 Node,读对端证书正文):
  不外发、不落盘;ssl 白名单放行,admin/ssl 可用,editor/匿名拒绝
- certprobe 改两级:首选国内机(带 X-Editor-Token),失败自动降级本地握手
- certProbe/certCheck 接线 EDITOR_API_BASE + EDITOR_TOKEN,撤掉 ?debug 诊断
- wrangler.toml 显式开 nodejs_compat(compat date 早于默认启用阈值)
- 手写 node:tls 最小类型声明(保持零依赖)
- seed-ssl-config.mjs 净化:真实凭据移到 secrets-backup/certkeeper-seeds.json,
  TOKEN_SECRET 改从 .dev.vars 读;脚本本体不含任何凭据
- role-perm 新增第 9 节 12 项(59/59),UI 探测 37 项全过
2026-10-06 15:55:22 +08:00

330 lines
18 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.
/**
* 证书管家后台页面的**真实浏览器验证**。
*
* 为什么不能只靠 node --check 和 tsc:这两个只保证「语法/类型对」,
* 不保证「DOM 真的画出来了」。本项目历史上出现过「数据对了但按钮点了没反应」
* 这类只有浏览器才能发现的问题。
*
* 打不通 wrangler dev 是意料之中 —— 本机沙箱会把 127.0.0.1 的连接掐掉
* (TCP 到 TIME_WAIT 但 HTTP 响应拿不到)。所以这里换一条路:
* · 用 CDP 开一个 **静态页**,把 /api/v2/* 全部用 fetch mock 拦掉
* · 用真实的 admin.js + admin.css + index.html(不复制、不简化),只替换数据源
* · 角色切换走**真实 boot 流程**:改 localStorage 的 atk.user → reload 页面,
* 由 admin.js 自己的 boot()/showApp()/renderTabs() 决定画什么
* 这样验证的是「前端代码画不画得出界面」,而不是「后端通不通」——
* 后端那一层已经由 tools/selftest-ssl.mjs 覆盖了 117 项。
*
* 踩坑记录:
* · 剥 <script> 时必须**保留 admin.js** 的标签,否则后续 mock 注入的正则
* 匹配不到任何东西 → 页面一个脚本都不加载 → 全白 + `state is not defined`
* · admin.js 是 IIFE,state/renderTabs/go 都不在全局上,别想着从控制台直接摸
*
* 用法:node tools/probe-ssl-ui.mjs
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9418;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---------------------------------------------------------------- mock 数据
const DAY = 86400000;
const MOCK = {
'/api/v2/ssl/overview': {
domains: [
{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tencent-usj', deploy: ['dogecloud', '1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 62 * DAY, expireText: '2026-12-07 06:59:59', issuer: 'TrustAsia Technologies, Inc.', updatedAt: Date.now(), daysLeft: 62, level: 'ok' },
{ name: 't-t.live', san: ['t-t.live', '*.t-t.live'], dns: 'tencent-tt', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 83 * DAY, expireText: '2026-12-27 16:02:51', issuer: "Let's Encrypt", updatedAt: Date.now(), daysLeft: 83, level: 'ok' },
{ name: '200181.xyz', san: ['200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 19 * DAY, expireText: '2026-10-25 12:56:26', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 19, level: 'warn' },
{ name: 'new.example.com', san: ['new.example.com'], dns: 'cloudflare', deploy: [], disabled: false, hasCert: false, expireAt: null, expireText: null, issuer: '', updatedAt: null, daysLeft: null, level: 'none' },
],
orphans: ['old.example.org'],
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
mailEnabled: true,
serverTime: '2026-10-06 12:00:00',
},
// 实测探针的返回形状 —— 与真接口一致(国内机 /ssl-probe 的字段)
'/api/v2/ssl/probe': {
ok: true, handshakeFailed: false, host: 'usj.cc', tlsVersion: 'TLSv1.3',
subject: 'usj.cc', issuer: 'TrustAsia Technologies, Inc.',
altNames: ['usj.cc', '*.usj.cc'],
notAfter: Date.now() + 62 * DAY, notBefore: Date.now() - 28 * DAY,
daysLeft: 62, notAfterText: '2026-12-07 06:59:59',
},
'/api/v2/ssl/access': {
items: [
{ name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } },
{ name: 'cloudflare', type: 'cloudflare', note: '200181.xyz 的 DNS', fields: { apiToken: 'ALTp********9xQz' } },
{ name: 'dogecloud', type: 'dogecloud', note: '', fields: { accessKey: 'dk1a********8f00', secretKey: 'mmN7****11bc' } },
{ name: 'old-broken', type: '', note: '', fields: {}, unreadable: true },
],
types: ['tencentcloud', 'cloudflare', 'dogecloud', '1panel', 'acme-eab'],
},
'/api/v2/ssl/log': {
items: [
{ at: Date.now() - 60000, level: 'info', action: 'check', message: '群林: 检查 3 个域名,2 个一致,1 个需关注' },
{ at: Date.now() - 3600000, level: 'warn', action: 'check', message: '群林: 200181.xyz 剩余 19 天,低于阈值 30' },
{ at: Date.now() - 86400000, level: 'info', action: 'config', message: '群林: 保存配置:3 个域名,提醒邮箱 1 个' },
{ at: Date.now() - 172800000, level: 'error', action: 'notify', message: '群林: 邮件未配置(缺少 RESEND_API_KEY)' },
],
},
'/api/v2/ssl/certs': { items: [], warnDays: 30 },
};
// ---------------------------------------------------------------- 造静态页
const work = mkdtempSync(join(tmpdir(), 'ssl-ui-'));
const pubDir = join(work, 'public');
mkdirSync(join(pubDir, 'admin'), { recursive: true });
cpSync(join(root, 'public/admin/admin.css'), join(pubDir, 'admin/admin.css'));
cpSync(join(root, 'public/admin/admin.js'), join(pubDir, 'admin/admin.js'));
// 真实的 admin.html 骨架。剥掉外链脚本时**必须放过 admin.js**(负向先行),
// 否则后面往里注 mock 的正则会匹配不到 —— 这是上一版全白的根因。
const realHtml = readFileSync(join(root, 'public/admin/index.html'), 'utf8');
const html = realHtml.replace(
/<script(?![^>]*admin\.js)[^>]*\bsrc=["'][^"']*["'][^>]*><\/script>/g,
'',
);
// mock 注入脚本:在 admin.js 之前执行,把 fetch 换成路由表。
// /api/v2/user 动态读 localStorage —— 角色切换测试全靠它(改完 reload 即走真实 boot)。
const mockJs = `
(function () {
const MOCK = ${JSON.stringify(MOCK)};
const ok = (obj, status) => new Response(JSON.stringify(obj), {
status: status || 200, headers: { 'Content-Type': 'application/json' },
});
window.fetch = function (input, init) {
const url = typeof input === 'string' ? input : (input && input.url) || '';
let path = url;
try { path = new URL(url, location.href).pathname; } catch (e) {}
if (path === '/api/v2/user') {
let u = null;
try { u = JSON.parse(localStorage.getItem('atk.user') || 'null'); } catch (e) {}
return Promise.resolve(ok({ user: u, is_login: !!u }));
}
// 让 /admin/session 明确返回「不是国内机模式」,走 Cloudflare 分支
if (path.endsWith('/admin/session')) {
return Promise.resolve(new Response('{"msg":"Not Found"}', { status: 404, headers: { 'Content-Type': 'application/json' } }));
}
if (MOCK[path]) return Promise.resolve(ok(MOCK[path]));
return Promise.resolve(ok({}));
};
// 预置一个已登录的管理员会话,跳过登录门。
// ★ 只在缺省时写入 —— 角色切换测试靠「改 atk.user → reload」,
// 如果这里每次都重置,reload 后角色又被改回管理员了。
if (!localStorage.getItem('atk.token')) localStorage.setItem('atk.token', 'fake-admin-token');
if (!localStorage.getItem('atk.user')) localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' }));
})();
`;
writeFileSync(join(pubDir, 'admin', 'mock.js'), mockJs);
// 注入 mock.js(必须排在 admin.js 前面)
const finalHtml = html.replace(
/(<script[^>]*src=["'][^"']*admin\.js["'][^>]*><\/script>)/,
'<script src="./mock.js"></script>\n$1',
);
if (finalHtml === html) throw new Error('mock 注入失败:没找到 admin.js 的 script 标签');
writeFileSync(join(pubDir, 'admin', 'index.html'), finalHtml);
// ---------------------------------------------------------------- 起服务 + CDP
const { createServer } = await import('node:http');
const server = createServer((req, res) => {
const p = new URL(req.url, 'http://x').pathname;
const file = p === '/' || p === '/admin/' || p === '/admin' ? '/admin/index.html' : p;
const full = join(pubDir, file);
try {
const buf = readFileSync(full);
const ext = full.slice(full.lastIndexOf('.'));
const ct = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': ct });
res.end(buf);
} catch {
res.writeHead(404); res.end('nf');
}
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const pagePort = server.address().port;
const URL_ = `http://127.0.0.1:${pagePort}/admin/`;
const userDir = mkdtempSync(join(tmpdir(), 'cdp-ssl-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1320,1500', 'about:blank',
], { stdio: 'ignore' });
let fail = 0;
const results = [];
const t = (name, ok, extra) => {
results.push((ok ? ' ✓ ' : ' ✗ ') + name + (ok || !extra ? '' : ' → ' + extra));
if (!ok) fail++;
};
try {
for (let i = 0; i < 50; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
});
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => {
const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params }));
});
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.result && r.result.exceptionDetails) return 'EXC: ' + JSON.stringify(r.result.exceptionDetails.exception);
return r.result?.result?.value ?? null;
};
// 收集页面错误 —— 这是「画不出来」类 bug 的第一手证据
const errs = [];
ws.addEventListener('message', (e) => {
const m = JSON.parse(e.data);
if (m.method === 'Runtime.exceptionThrown') {
errs.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
}
});
await send('Runtime.enable');
await send('Page.enable');
await sleep(3500);
// ---------- 基本渲染(管理员,走真实 boot)
t('登录门已隐藏', (await ev(`document.querySelector('#gate').hidden`)) === true);
t('主界面已显示', (await ev(`document.querySelector('#app').hidden`)) === false);
t('身份栏是「群林」', String(await ev(`document.querySelector('#who')?.textContent`)).includes('群林'));
// ---------- 导航里能看到「证书管家」(管理员全量菜单)
const navText = await ev(`document.querySelector('#tabs').textContent`);
t('导航含「证书管家」', String(navText).includes('证书管家'), String(navText));
// ---------- 点进证书管家
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); return !!b; })()`);
await sleep(2200);
t('顶部标题为「证书管家」', String(await ev(`document.querySelector('.page-head h2')?.textContent`)).includes('证书管家'));
const view = String(await ev(`document.querySelector('#view').innerHTML`));
t('域名表格已渲染', view.includes('usj.cc') && view.includes('t-t.live'));
t('SAN 显示出来了', view.includes('*.usj.cc'));
t('签发方显示出来了', view.includes('LiteSSL'));
t('临期域名有告警横幅', view.includes('200181.xyz') && view.includes('临期'));
t('待登记的域名有「无记录」标记', view.includes('new.example.com'));
t('孤儿记录提示出现', view.includes('old.example.org'));
// 剩余天数的数值
t('剩余天数渲染为数字', /61/.test(view) && /19/.test(view));
t('「实测」按钮存在', view.includes('ssl-probe'));
t('「登记」按钮存在', view.includes('ssl-import'));
t('「+ 域名」按钮存在', view.includes('ssl-add-domain'));
// ---------- 凭据区
await sleep(600);
const acc = String(await ev(`document.querySelector('#ssl-access')?.innerHTML`));
t('凭据列表已渲染', acc.includes('tencent-usj') && acc.includes('cloudflare'));
t('★ 凭据只显示脱敏值', acc.includes('AKID********3f2a') && !acc.includes('AKIDabcdefg'));
t('解不开的凭据被标出来', acc.includes('old-broken') && acc.includes('读不出'));
t('凭据有编辑/删除按钮', acc.includes('ssl-access-edit') && acc.includes('ssl-access-del'));
// ---------- 日志区
const logHtml = String(await ev(`document.querySelector('#ssl-log')?.innerHTML`));
t('执行日志已渲染', logHtml.includes('检查 3 个域名') && logHtml.includes('低于阈值'));
// ---------- 通知设置
const notify = String(await ev(`document.querySelector('#ssl-notify-cfg')?.innerHTML`));
t('通知收件人回显', notify.includes('177018615@qq.com'));
t('提醒天数回显', notify.includes('value="30"'));
// ---------- 打开「+ 域名」弹窗
await ev(`document.querySelector('[data-act="ssl-add-domain"]').click()`);
await sleep(900);
const modalTitle = await ev(`document.querySelector('#modalTitle')?.textContent`);
t('「+ 域名」弹窗打开了', String(modalTitle).includes('新增域名'), String(modalTitle));
const modalBody = String(await ev(`document.querySelector('#modalBody')?.innerHTML`));
t('弹窗里有 DNS 凭据下拉', modalBody.includes('sdDns') && modalBody.includes('tencent-usj'));
t('弹窗里有部署目标复选', modalBody.includes('sdDeploy_dogecloud') && modalBody.includes('sdDeploy_1panel'));
t('弹窗里有 SAN 输入', modalBody.includes('sdSan'));
await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`);
await sleep(400);
// ---------- 打开凭据编辑弹窗(含脱敏回显)
await ev(`document.querySelector('[data-act="ssl-access-edit"][data-name="tencent-usj"]').click()`);
await sleep(900);
const accModal = String(await ev(`document.querySelector('#modalBody')?.innerHTML`));
t('凭据弹窗打开了', accModal.includes('saName') && accModal.includes('saType'));
t('★ 凭据弹窗里回显的是脱敏值', accModal.includes('AKID********3f2a'));
t('凭据弹窗提示「留空则不改」', accModal.includes('留空则不改') || accModal.includes('留空'));
await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`);
await sleep(400);
// ---------- 页面无 JS 异常(管理员阶段)
const realErrs = errs.filter((e) => !/favicon|ERR_|Failed to load resource/i.test(String(e)));
t('★ 页面无未捕获 JS 异常', realErrs.length === 0, realErrs.slice(0, 3).join(' | '));
// ---------- ★ SSL 专属角色:改 localStorage → 真实 reload → 走真实 boot
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 9, name: '阿美', is_admin: false, role: 'ssl' })); 'ok'`);
await send('Page.reload').catch(() => {});
await sleep(3000);
const sslAppShown = (await ev(`document.querySelector('#app').hidden`)) === false;
t('★ SSL 角色能进主界面', sslAppShown);
const sslTabs = String(await ev(`document.querySelector('#tabs')?.textContent`));
t('★ SSL 角色只看到「证书管家」', sslAppShown && sslTabs.includes('证书管家') && !sslTabs.includes('评论管理') && !sslTabs.includes('文章编辑') && !sslTabs.includes('仪表盘'), sslTabs);
const sslView = String(await ev(`document.querySelector('#view').innerHTML`));
t('★ SSL 角色落地页就是证书管家', sslAppShown && sslView.includes('usj.cc'), sslView.slice(0, 120));
const sslWho = String(await ev(`document.querySelector('#who')?.textContent`));
t('★ 身份栏标了 SSL', sslWho.includes('阿美') && sslWho.includes('SSL'), sslWho);
// ---------- ★ 编辑角色:看不到证书管家
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 8, name: '小明', is_admin: false, role: 'editor' })); 'ok'`);
await send('Page.reload').catch(() => {});
await sleep(3000);
const edAppShown = (await ev(`document.querySelector('#app').hidden`)) === false;
const edTabs = String(await ev(`document.querySelector('#tabs')?.textContent`));
t('★ 编辑角色进得去主界面', edAppShown);
t('★ 编辑角色看得到「文章编辑」', edAppShown && edTabs.includes('文章编辑'), edTabs);
t('★ 编辑角色看不到「证书管家」', !edTabs.includes('证书管家'), edTabs);
// ---------- 截图(人眼复核)
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' })); 'ok'`);
await send('Page.reload').catch(() => {});
await sleep(3000);
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`);
await sleep(2000);
// 点一次「实测」——本轮改的就是探测链路,把结果弹窗截进画面才看得见。
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-probe"]'); if (b) b.click(); })()`);
await sleep(2000);
await ev(`document.documentElement.setAttribute('data-theme','light')`);
await sleep(500);
{
const s = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
// ★ 落点必须用**项目**目录(root),不是临时目录:临时目录跑完整个被删,
// 截图写进去等于没写(上一版就是这样,人眼永远看不到)。
const out = join(root, '.editor-tmp', 'ssl-panel-light.png');
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, Buffer.from(s.result.data, 'base64'));
console.log('\n截图:' + out);
}
console.log('\n' + results.join('\n'));
console.log('\n' + '='.repeat(56));
console.log(fail ? `失败 ${fail} 项` : '全部通过 ✓');
ws.close();
} finally {
chrome.kill();
server.close();
}
process.exit(fail ? 1 : 0);