Files
blog/blog-admin/tools/probe-ssl-ui.mjs
T
zqlit 43dbc8b75f feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环
证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。

新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts        ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
                     thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts    部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts   编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
                     → 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts      新增 POST /ssl/issue、GET /ssl/renew-check、
                     POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron    每日 04:10 自动续期检查;cpu_ms 提到 60s

与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。

实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
  cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory

测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
2026-10-06 16:59:37 +08:00

368 lines
20 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/renew-check': {
threshold: 30,
items: [
{ domain: 'usj.cc', host: 'usj.cc', source: 'live', daysLeft: 62, action: 'ok', threshold: 30, issuer: 'TrustAsia Technologies, Inc.', error: null },
{ domain: 't-t.live', host: 't-t.live', source: 'live', daysLeft: 83, action: 'ok', threshold: 30, issuer: "Let's Encrypt", error: null },
{ domain: '200181.xyz', host: '200181.xyz', source: 'live', daysLeft: 19, action: 'renew', threshold: 30, issuer: 'LiteSSL', error: null },
],
},
// 环境自检(只读)
'/api/v2/ssl/selfcheck': {
threshold: 30,
summary: { total: 5, failed: 0 },
items: [
{ name: 'CA litessl', kind: 'ca', ok: true, detail: '目录可达,EAB 已配对' },
{ name: 'DNS tencent-usj', kind: 'dns', ok: true, detail: 'tencentcloud 凭据可用,能读取 usj.cc 的 TXT(现存 0 条)' },
{ name: 'DNS cloudflare', kind: 'dns', ok: true, detail: 'cloudflare 凭据可用,能读取 200181.xyz 的 TXT(现存 0 条)' },
{ name: '部署 dogecloud', kind: 'deploy', ok: true, detail: '多吉云凭据可用' },
{ name: '部署 1panel', kind: 'deploy', ok: true, detail: '1Panel 凭据可用,站点 usj.cc 已找到' },
],
},
'/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'));
// 本轮新增的三个动作
t('★ 「环境自检」按钮存在', view.includes('ssl-selfcheck'));
t('★ 「该续期了吗」按钮存在', view.includes('ssl-renew-check'));
t('★ 每行有「签发」按钮', view.includes('data-act="ssl-issue"'));
// ---------- 环境自检弹窗(本轮新增)
{
const before = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`));
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`);
await sleep(1500);
const m = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`));
t('★ 自检弹窗打开', m !== before && m.includes('环境自检'), m.slice(0, 80));
t('★ 自检列出各项', m.includes('CA') && m.includes('DNS') && m.includes('部署'));
t('★ 自检说明「不会签发证书」', m.includes('不会签发证书'));
await ev(`(() => { const b = document.querySelector('#modal button[data-act="modal-close"]'); if (b) b.click(); })()`);
await sleep(400);
}
// ---------- 凭据区
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-selfcheck"]'); 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);