Files
blog/blog-admin/tools/probe-ssl-ui.mjs
T
zqlit a40a92f526 feat: SSL 证书管家 —— 后台面板 + 专属角色 + 国内机放行
集成在 api.200181.xyz(同一个 Worker),国内机 writeapi.usj.cc 同步可用。

新增第三档后台角色 'ssl':
- 只拿证书管家钥匙,看不到评论/文章/用户等模块
- 判定正着枚举放行(canManageSSL = admin|ssl),不用排除法,
  免得以后新增角色静默获得私钥权限
- 鉴权只认 Bearer 会话,绝不走 isAdminRequest —— 后者有 Artalk
  老客户端的 query 兜底,一旦进来的就是 TLS 私钥

数据(复用 RSS_KV,前缀 certkeeper:):
- 凭据一条一键,避开 KV 读-改-写无事务导致的并发丢数据
- 私钥/AK-SK 一律 AES-GCM 密文(TOKEN_SECRET 经 PBKDF2 派生)
- 列表接口只回显前 4 后 4 位,明文不进内存
- 配置读失败抛错而非返回空,避免一次保存覆盖线上配置

只读监控:
- probeTls 走 cloudflare:sockets 拿证书正文,实现到期分级
  与「库里记录 vs 线上实测」对比(match/mismatch/live-only/unreachable)
- 到期提醒邮件(HTML 已转义)

国内机 editor-api:
- identify 识别 ssl 角色;业务分支前白名单,ssl 只能碰 /health、
  /admin/session、/admin/logout 与 /api/v2/ssl*
- /api/v2/ssl* 反代放行 admin + ssl(RSS 仍是 admin-only)
- posts.mjs 越权兜底方向修正:owns() 从「非 editor 即放行」改为
  「只有 admin 不受限」,漏进来的 ssl 被当受限编辑而非管理员全放行
- createPost 显式拒绝非写作角色

验证:typecheck ✓ / selftest:ssl 117 项 ✓ / 浏览器 37 项 ✓
2026-10-06 14:19:01 +08:00

317 lines
17 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() + 61 * DAY, expireText: '2026-12-07 00:00:00', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 61, 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() + 82 * DAY, expireText: '2026-12-27 00:00:00', issuer: "Let's Encrypt", updatedAt: Date.now(), daysLeft: 82, level: 'ok' },
{ name: '200181.xyz', san: ['200181.xyz', '*.200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 19 * DAY, expireText: '2026-10-25 00:00:00', 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',
},
'/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(`document.documentElement.setAttribute('data-theme','light')`);
await sleep(500);
{
const s = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
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);