From a40a92f5261c0a7ae2ddd14caec5c11350163127 Mon Sep 17 00:00:00 2001 From: zqlit Date: Tue, 6 Oct 2026 14:19:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSL=20=E8=AF=81=E4=B9=A6=E7=AE=A1?= =?UTF-8?q?=E5=AE=B6=20=E2=80=94=E2=80=94=20=E5=90=8E=E5=8F=B0=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=20+=20=E4=B8=93=E5=B1=9E=E8=A7=92=E8=89=B2=20+=20?= =?UTF-8?q?=E5=9B=BD=E5=86=85=E6=9C=BA=E6=94=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集成在 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 项 ✓ --- blog-admin/.gitignore | 1 + blog-admin/package.json | 2 + blog-admin/public/admin/admin.css | 3 + blog-admin/public/admin/admin.js | 545 +++++++++++++++++++++++-- blog-admin/src/index.ts | 12 + blog-admin/src/lib/certprobe.ts | 175 +++++++++ blog-admin/src/lib/certstore.ts | 306 +++++++++++++++ blog-admin/src/lib/certvault.ts | 126 ++++++ blog-admin/src/lib/role.ts | 36 +- blog-admin/src/routes/ssl.ts | 633 ++++++++++++++++++++++++++++++ blog-admin/src/types.ts | 5 +- blog-admin/tools/probe-ssl-ui.mjs | 316 +++++++++++++++ blog-admin/tools/selftest-ssl.mjs | 475 ++++++++++++++++++++++ editor-api/server.mjs | 79 +++- editor-api/src/posts.mjs | 25 +- 函数版证书管家-方案.md | 111 +++++- 16 files changed, 2791 insertions(+), 59 deletions(-) create mode 100644 blog-admin/src/lib/certprobe.ts create mode 100644 blog-admin/src/lib/certstore.ts create mode 100644 blog-admin/src/lib/certvault.ts create mode 100644 blog-admin/src/routes/ssl.ts create mode 100644 blog-admin/tools/probe-ssl-ui.mjs create mode 100644 blog-admin/tools/selftest-ssl.mjs diff --git a/blog-admin/.gitignore b/blog-admin/.gitignore index df347610..f10fc1e3 100644 --- a/blog-admin/.gitignore +++ b/blog-admin/.gitignore @@ -2,6 +2,7 @@ node_modules/ .wrangler/ .selftest-build/ +.selftest-ssl/ .mail-build/ *.log diff --git a/blog-admin/package.json b/blog-admin/package.json index e3808ca7..7af84376 100644 --- a/blog-admin/package.json +++ b/blog-admin/package.json @@ -7,6 +7,8 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", + "selftest:ssl:build": "tsc src/routes/ssl.ts src/lib/role.ts src/lib/certstore.ts src/lib/certvault.ts src/lib/certprobe.ts --outDir .selftest-ssl --module commonjs --target es2022 --moduleResolution node --strict --types ./node_modules/@cloudflare/workers-types --skipLibCheck --esModuleInterop --resolveJsonModule", + "selftest:ssl": "npm run selftest:ssl:build && node tools/selftest-ssl.mjs", "db:init:local": "wrangler d1 execute artalk-cf --local --file=./schema.sql", "db:init:remote": "wrangler d1 execute artalk-cf --remote --file=./schema.sql", "import:gen": "python tools/import_artrans.py", diff --git a/blog-admin/public/admin/admin.css b/blog-admin/public/admin/admin.css index ccf7210d..da5c8187 100644 --- a/blog-admin/public/admin/admin.css +++ b/blog-admin/public/admin/admin.css @@ -427,6 +427,9 @@ select{appearance:none;cursor:pointer;padding-right:28px; .tag.blue{color:var(--accent);background:var(--accent-soft)} .tag.warn{color:var(--pend-fg);background:var(--pend-bg)} .tag.dang{color:var(--danger);background:var(--danger-soft)} +/* SSL 管理员徽章:故意用与「编辑」不同的色(编辑是中性、管理员是主色), + 一眼能分清这是第三种角色而不是某个管理员的临时标记 */ +.tag.ssl{color:var(--warning);background:rgba(192,154,70,.14)} .tag.line{color:var(--muted);border:1px solid var(--line)} /* ---------------------------------------------------------- 评论列表 */ diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index 8573975d..ffadd13e 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -46,25 +46,32 @@ const truncate = (s, n) => (String(s || '').length > n ? String(s).slice(0, n) + // ============================================================ 角色 // -// 后台两档角色(服务端 users.role,权威实现在 blog-admin/src/lib/role.ts): -// admin 管理员 —— 全部模块 -// editor 编辑 —— 只有「文章编辑」,而且只能写 / 发布自己的文章 +// 后台三档角色(服务端 users.role,权威实现在 blog-admin/src/lib/role.ts): +// admin 管理员 —— 全部模块 +// editor 编辑 —— 只有「文章编辑」,而且只能写 / 发布自己的文章 +// ssl SSL 管理员 —— 只有「证书管家」,碰不到评论 / 文章 / 用户 // 服务端已经归一化过一次(is_admin 优先),这里再兜一次, // 兼容缓存里的老 state.me(localStorage 可能还留着改造前的结构)。 function roleOf(u) { if (!u) return 'user'; if (u.is_admin) return 'admin'; - return u.role === 'editor' ? 'editor' : u.role === 'admin' ? 'admin' : 'user'; + if (u.role === 'editor' || u.role === 'ssl') return u.role; + return u.role === 'admin' ? 'admin' : 'user'; } /** 当前登录者是不是编辑(决定导航能画几项、作者能不能改) */ function isEditor() { return roleOf(state.me) === 'editor'; } +/** 当前登录者是不是 SSL 管理员(只该看到「证书管家」一项) */ +function isSslOnly() { return roleOf(state.me) === 'ssl'; } /** 能进写作后台的角色 */ function canEnterBackend() { const r = roleOf(state.me); return r === 'admin' || r === 'editor'; } +/** 能进证书管家的角色 —— ★ 正着枚举,别写成「非编辑即放行」 */ +function canUseCert() { const r = roleOf(state.me); return r === 'admin' || r === 'ssl'; } /** 用户列表里的角色标签 */ function roleTag(u) { const r = roleOf(u); if (r === 'admin') return ' 管理员'; if (r === 'editor') return ' 编辑'; + if (r === 'ssl') return ' SSL'; return ''; } @@ -86,6 +93,8 @@ const state = { // 文章编辑 po: { q: '', page: 1, perPage: 20, total: 0, totalPages: 1, rows: [], busy: false }, ed: null, // 打开某篇时:{ id, slug, post, fm, body, dirty, busy, git } + // 证书管家(SSL):概述 / 表单草稿 / 正在编辑的凭据名 + ssl: { ov: null, cfg: null, busy: false, editing: '', accessItems: null, probe: {} }, }; const TABS = [ @@ -98,6 +107,7 @@ const TABS = [ { id: 'links', label: '友链', group: '订阅中心', icon: 'links' }, { id: 'settings', label: '系统设置', group: '系统', icon: 'settings' }, { id: 'status', label: '运行状态', group: '系统', icon: 'status' }, + { id: 'ssl', label: '证书管家', group: '系统', icon: 'lock' }, ]; // 16px 线性图标(stroke 继承 currentColor) @@ -113,6 +123,7 @@ const ICONS = { links: I(''), settings: I(''), status: I(''), + lock: I(''), moon: I(''), out: I(''), eye: I(''), @@ -275,13 +286,18 @@ function showGate() { function showApp() { $('#gate').hidden = true; $('#app').hidden = false; - // 侧栏显示身份:编辑角色标一下,免得 TA 以为自己权限跟管理员一样 - $('#who').innerHTML = esc((state.me && (state.me.name || state.me.email)) || 'admin') + - (isEditor() ? ' 编辑' : ''); - $('#footR').textContent = 'API v2 · 站点:' + (state.site || '—'); + // 侧栏显示身份:编辑 / SSL 管理员标一下,免得 TA 以为自己权限跟管理员一样 + const tag = isEditor() + ? ' 编辑' + : isSslOnly() + ? ' SSL' + : ''; + $('#who').innerHTML = esc((state.me && (state.me.name || state.me.email)) || 'admin') + tag; + $('#footR').textContent = state.direct ? '编辑器走国内机 · 不经过 Cloudflare' : 'API v2 · 站点:' + (state.site || '—'); renderTabs(); - // 编辑进来直接落在文章列表:仪表盘/评论这些它没有权限,默认页只会是 403 - go(isEditor() ? 'posts' : 'dash'); + // 各自落到自己唯一有权限的那一页:仪表盘/评论这些它们没有权限, + // 默认页只会是一片 403 + go(isEditor() ? 'posts' : isSslOnly() ? 'ssl' : 'dash'); } function logout(msg) { @@ -346,7 +362,14 @@ function renderTabs() { ''; // 编辑角色:订阅源/友链在国内机侧已经被拒(server.mjs 只放管理员走 rssProxy), - // 而且它也没有 Cloudflare 的完整会话 —— 干脆不给它画 + // 而且它也没有 Cloudflare 的完整会话 —— 干脆不给它画。 + // ★ SSL 管理员在国内机侧除了证书管家什么都不该看到;证书接口由本机 + // editor-api 反代到 Cloudflare(见 server.mjs 的 SSL_PATH_PREFIXES), + // 所以这一项在国内线路下同样可用。 + if (isSslOnly()) { + $('#tabs').innerHTML = t('ssl', '证书管家'); + return; + } $('#tabs').innerHTML = t('posts', '文章编辑') + (isEditor() @@ -355,14 +378,24 @@ function renderTabs() { '' + t('feeds', '订阅源') + t('links', '友链') + '' + + '' + + t('ssl', '证书管家') + + '' + '' + ''); return; } // 分组渲染:group 为空的(仪表盘)单独在顶部;组间加分隔线 - // ★ 编辑角色只画「文章编辑」一项 —— 其余模块服务端本来就是 403 - // (见 routes/admin.ts 的 requireAdmin),画出来只会让人点进一片报错。 - const tabs = isEditor() ? TABS.filter((t) => t.id === 'posts') : TABS; + // ★ 权限裁剪:服务端本来就 403(见 routes/admin.ts 的 requireAdmin、 + // routes/ssl.ts 的 auth),画出来只会让人点进一片报错。 + // editor → 只留「文章编辑」 + // ssl → 只留「证书管家」 + // ★ 用 isEditor()/isSslOnly() 各自**正着**判断,别写成一条 filter 链 —— + // 以后新增角色时,漏写一个分支的后果是「默认看到全部模块」。 + let tabs; + if (isEditor()) tabs = TABS.filter((t) => t.id === 'posts'); + else if (isSslOnly()) tabs = TABS.filter((t) => t.id === 'ssl'); + else tabs = TABS; let html = ''; let lastGroup = null; for (const t of tabs) { @@ -383,10 +416,14 @@ function renderTabs() { // 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台 // (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie) // 编辑也能用 —— 跳过去还是编辑,拿不到管理员权限(签名覆盖了身份) - html += '' + - '' + - ''; + // ★ SSL 管理员不给这个入口:国内机后台目前只认 admin/editor 两种角色 + // (见 editor-api 的 handoff 校验),跳过去会签不出会话。 + if (!isSslOnly()) { + html += '' + + '' + + ''; + } $('#tabs').innerHTML = html; } @@ -422,6 +459,7 @@ async function go(tab) { else if (tab === 'status') await viewStatus(); else if (tab === 'feeds') await viewFeeds(); else if (tab === 'links') await viewLinks(); + else if (tab === 'ssl') await viewSSL(); } catch (e) { if (e.status === 401 || e.status === 403) { logout('会话已失效,请重新登录'); return; } if (state.tab !== tab) return; // 已切走,别用旧错误覆盖新视图 @@ -734,8 +772,9 @@ async function loadUsers() { } /** - * 角色下拉 —— 一个控件同时表达三档。 - * 拆成「管理员 checkbox + 编辑 checkbox」会有四种组合,其中两种是废的。 + * 角色下拉 —— 一个控件同时表达四档。 + * 拆成「管理员 checkbox + 编辑 checkbox + SSL checkbox」会有八种组合, + * 其中七种是废的。 */ function roleSelectHtml(u, id) { const cur = roleOf(u); @@ -746,6 +785,7 @@ function roleSelectHtml(u, id) { '' + (self @@ -1901,6 +1941,373 @@ function toggleTheme() { applyTheme(next); } +// ============================================================ 视图:证书管家 +// +// 数据全部来自 /api/v2/ssl/*(Worker 侧,鉴权放行 admin + ssl)。 +// 国内机模式下同源请求会被本机 editor-api 反代到 Cloudflare +// (见 editor-api/server.mjs 的 SSL_PATH_PREFIXES),前端代码完全一致。 +// +// ★ 前端**不做任何权限判断**:能不能用由服务端的 auth() 决定, +// 这里只负责「服务端 403 了就别把界面留在半死状态」。 + +const SSL_ACCESS_TYPES = [ + { v: 'tencentcloud', label: '腾讯云 CAM(DNSPod DNS-01)', fields: [['secretId', 'SecretId'], ['secretKey', 'SecretKey']] }, + { v: 'cloudflare', label: 'Cloudflare API Token(DNS + 部署)', fields: [['apiToken', 'API Token']] }, + { v: 'dogecloud', label: '多吉云 AK/SK(CDN 证书部署)', fields: [['accessKey', 'AccessKey'], ['secretKey', 'SecretKey']] }, + { v: '1panel', label: '1Panel 面板(API Key)', fields: [['serverUrl', '面板地址'], ['apiKey', 'API Key']] }, + { v: 'acme-eab', label: 'ACME EAB(LiteSSL / ZeroSSL)', fields: [['directoryUrl', 'Directory URL'], ['eabKid', 'EAB Kid'], ['eabHmacKey', 'EAB HmacKey']] }, +]; +const SSL_DEPLOYS = [ + { v: 'dogecloud', label: '多吉云 CDN' }, + { v: '1panel', label: '1Panel' }, + { v: 'tencentcloud-eo', label: '腾讯云 EdgeOne' }, +]; + +function sslApi(path, opt) { + return api('/ssl' + path, opt); +} + +async function viewSSL() { + const v = $('#view'); + v.innerHTML = '
正在读证书状态…
'; + const ov = await sslApi('/overview'); + if (state.tab !== 'ssl') return; + state.ssl.ov = ov; + state.ssl.accessItems = null; // 每次进这一页都重新拉,免得拿着过期列表 + v.innerHTML = head('证书管家', 'CERT KEEPER', + '域名 ' + ov.domains.length + ' 个 · 提醒阈值 ' + ov.notify.daysBefore + ' 天', + '' + + '' + + '' + + (ov.mailEnabled ? '' : '邮件未配置')) + + sslWarnBanner(ov) + + '
' + sslDomainTable(ov) + '
' + + '
' + + '

凭据(密钥只显示尾 4 位)

' + + '' + + '
加载中…
' + + '

最近执行记录

' + + '' + + '
加载中…
' + + '
' + + '

通知设置

' + + '
' + sslNotifyForm(ov) + '
'; + + await Promise.all([loadSslAccess(), loadSslLog()]); +} + +/** 顶部横幅:把最需要行动的信息顶到最上面 */ +function sslWarnBanner(ov) { + const expiring = ov.domains.filter((d) => d.level === 'danger' || d.level === 'warn'); + const noCert = ov.domains.filter((d) => d.hasCert === false && !d.disabled); + if (!expiring.length && !noCert.length) return ''; + const parts = []; + if (expiring.length) { + parts.push('' + expiring.length + ' 个域名即将或已经过期:' + + expiring.map((d) => esc(d.name) + '(' + (d.daysLeft == null ? '无记录' : d.daysLeft + ' 天') + ')').join('、')); + } + if (noCert.length) { + parts.push('' + noCert.length + ' 个域名还没有证书记录(只影响监控,不影响线上):' + + noCert.map((d) => esc(d.name)).join('、')); + } + return '
' + + parts.join('
') + '
'; +} + +function sslLevelTag(d) { + if (d.disabled) return '已停用'; + if (!d.hasCert) return '无记录'; + if (d.level === 'danger') return '' + (d.daysLeft < 0 ? '已过期' : '紧急') + ''; + if (d.level === 'warn') return '临期'; + return '正常'; +} + +function sslDomainTable(ov) { + if (!ov.domains.length) { + return '
还没有配置任何域名' + + '点右上角「+ 域名」加第一个。域名不会立刻签发证书,只是纳入监控与后续自动化。
'; + } + const rows = ov.domains.map((d) => + '' + + '' + esc(d.name) + '' + sslLevelTag(d) + + '
' + esc(d.san.join(' · ')) + '
' + + '' + (d.daysLeft == null ? '—' : (d.daysLeft < 0 ? '' + d.daysLeft + '' : d.daysLeft)) + '' + + '' + esc(d.issuer || '—') + '
' + esc(d.expireText || '—') + '
' + + '' + esc(d.dns || '—') + '' + + '' + (d.deploy.length ? d.deploy.map((t) => '' + esc(t) + '').join(' ') : '—') + '' + + '' + + ' ' + + ' ' + + '' + + '' + + '' + ).join(''); + return '
' + + '' + + '' + rows + '
域名 / SAN剩余天签发方 / 到期DNS 凭据部署目标
' + + (ov.orphans.length + ? '
库里有 ' + ov.orphans.length + + ' 条孤儿证书记录(域名已从配置里删掉):' + esc(ov.orphans.join('、')) + + '。不影响使用,要清理点上面的「登记」旁的删除,或忽略即可。
' + : ''); +} + +async function loadSslAccess() { + const box = $('#ssl-access'); + if (!box) return; + const d = await sslApi('/access'); + if (state.tab !== 'ssl' || !document.body.contains(box)) return; + const items = d.items || []; + state.ssl.accessItems = items; // 弹窗要用(凭据下拉 + 脱敏回显) + if (!items.length) { + box.innerHTML = '
还没有凭据' + + 'DNS-01 需要一把能改 DNS 的钥匙(腾讯云 CAM / Cloudflare Token),部署需要多吉云和 1Panel 的。' + + '密钥用 TOKEN_SECRET 派生密钥 AES-GCM 加密后落 KV,列表只回显尾 4 位。
'; + return; + } + box.innerHTML = '
' + items.map((a) => + '
' + + '' + esc(a.name) + '' + + ' ' + esc(a.type || '未知') + '' + + (a.unreadable ? ' 读不出' : '') + + '
' + + esc(Object.entries(a.fields).map(([k, v]) => k + '=' + v).join(' ') || (a.note || '(未填密钥)')) + + '
' + + ' ' + + '' + + '
').join('') + '
'; +} + +/** 打开弹窗前确保凭据列表已加载(正常路径下 viewSSL 已经拉过,这里只是兜底) */ +async function ensureAccessLoaded() { + if (state.ssl.accessItems) return; + const d = await sslApi('/access'); + state.ssl.accessItems = d.items || []; +} + +async function loadSslLog() { + const box = $('#ssl-log'); + if (!box) return; + const d = await sslApi('/log?limit=40'); + if (state.tab !== 'ssl' || !document.body.contains(box)) return; + const items = d.items || []; + box.innerHTML = items.length + ? '
' + items.map((l) => { + const color = l.level === 'error' ? 'var(--danger)' : l.level === 'warn' ? 'var(--warning)' : 'var(--muted)'; + return '
' + + esc(new Date(l.at + 8 * 3600e3).toISOString().slice(5, 16).replace('T', ' ')) + '' + + '[' + esc(l.action) + '] ' + + esc(l.message) + '
'; + }).join('') + '
' + : '
还没有执行记录
'; +} + +function sslNotifyForm(ov) { + return '
' + + '' + + '' + + '' + + '
'; +} + +/** 域名的编辑弹窗。新建时 opts.name 为空 */ +function sslDomainModal(name) { + const ov = state.ssl.ov || { domains: [], notify: { emails: [], daysBefore: 30 } }; + const cur = (ov.domains || []).find((d) => d.name === name) || { + name: '', san: [], dns: '', deploy: [], dogecloud_domains: [], one_panel_sites: [], disabled: false, + }; + const isNew = !name; + const accessNames = (state.ssl.accessItems || []).map((a) => a.name); + if (!accessNames.length) { + modal('先建凭据', '

配域名之前得先有 DNS 凭据 —— DNS-01 验证要往你的域名下写 TXT 记录。' + + '先去「凭据」那一栏加一把腾讯云 CAM 或 Cloudflare Token。

' + + '
'); + return; + } + const accessOpts = accessNames.map((n) => + '').join(''); + + modal(isNew ? '新增域名' : '编辑「' + name + '」', + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + (isNew ? '' : '') + + '' + + '' + + '
'); + + $('#sdSave').onclick = async () => { + const domains = JSON.parse(JSON.stringify((state.ssl.ov && state.ssl.ov.domains) || [])); + const clean = (s) => String(s || '').split(/[,,\n]/).map((x) => x.trim()).filter(Boolean); + const next = { + name: $('#sdName').value.trim().toLowerCase(), + san: clean($('#sdSan').value).map((s) => s.toLowerCase()), + dns: $('#sdDns').value, + deploy: SSL_DEPLOYS.filter((t) => $('#sdDeploy_' + t.v).checked).map((t) => t.v), + dogecloud_domains: clean($('#sdDoge').value), + one_panel_sites: clean($('#sdPanel').value), + ...($('#sdOff').checked ? { disabled: true } : {}), + }; + if (!next.name) { toast('✕ 主域名要填', true); return; } + if (!next.san.length) next.san = [next.name]; + const i = domains.findIndex((d) => d.name === name || d.name === next.name); + if (i >= 0) domains[i] = next; else domains.push(next); + const b = $('#sdSave'); b.dataset.busy = '1'; + try { + await sslApi('/config', { + method: 'POST', + body: { version: 1, notify: state.ssl.ov.notify, domains }, + }); + closeModal(); toast('✓ 已保存'); + await viewSSL(); + } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } + }; +} + +/** 凭据的新建 / 编辑弹窗 */ +function sslAccessModal(name) { + const cur = (state.ssl.accessItems || []).find((a) => a.name === name); + const isNew = !name; + const type = cur ? cur.type : 'tencentcloud'; + const def = SSL_ACCESS_TYPES.find((t) => t.v === type) || SSL_ACCESS_TYPES[0]; + + const typeOpts = SSL_ACCESS_TYPES.map((t) => + '').join(''); + + const fieldHtml = (fields) => fields.map(([k, label]) => { + const shown = cur && cur.fields && cur.fields[k] ? cur.fields[k] : ''; + return ''; + }).join(''); + + modal(isNew ? '新增凭据' : '编辑凭据「' + name + '」', + '' + + '' + + '
' + fieldHtml(def.fields) + '
' + + '' + + '
' + + '密钥用 TOKEN_SECRET 派生的密钥 AES-GCM 加密后才写进 KV,列表接口只回显尾 4 位。' + + '编辑时留空的字段保持不变 —— 不用为了改个备注重新粘一遍密钥。
' + + '
' + + '' + + '' + + '
'); + + const rerenderFields = () => { + const t = SSL_ACCESS_TYPES.find((x) => x.v === $('#saType').value) || SSL_ACCESS_TYPES[0]; + const keep = {}; + $$('#saFields [data-field]').forEach((i) => { keep[i.dataset.field] = i.value; }); + $('#saFields').innerHTML = t.fields.map(([k, label]) => + '').join(''); + }; + $('#saType').onchange = rerenderFields; + + $('#saSave').onclick = async () => { + const fields = {}; + $$('#saFields [data-field]').forEach((i) => { + const v = i.value.trim(); + // 回显的是脱敏值,没动过就丢掉(服务端也认这个规则,双保险) + if (v && !v.includes('****')) fields[i.dataset.field] = v; + }); + const body = { + name: $('#saName').value.trim(), + type: $('#saType').value, + note: $('#saNote').value.trim(), + fields, + }; + if (!body.name) { toast('✕ 凭据名要填', true); return; } + const b = $('#saSave'); b.dataset.busy = '1'; + try { + await sslApi('/access', { method: 'POST', body }); + closeModal(); toast('✓ 已保存'); + await viewSSL(); + } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } + }; +} + +/** 登记证书(手工粘贴 PEM) */ +function sslImportModal(domain) { + modal('登记证书' + (domain ? ':' + domain : ''), + '' + + '' + + '' + + '' + + ''); + + $('#siSave').onclick = async () => { + const b = $('#siSave'); b.dataset.busy = '1'; + try { + await sslApi('/cert/import', { + method: 'POST', + body: { + domain: $('#siDomain').value.trim(), + cert: $('#siCert').value, + key: $('#siKey').value, + issuer: $('#siIssuer').value.trim(), + }, + }); + closeModal(); toast('✓ 已登记'); + await viewSSL(); + } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } + }; +} + +/** 实测结果弹窗 —— 结构化展示,别再让用户去 openssl */ +function sslProbeModal(host, r) { + const dl = (ms) => ms == null ? '—' : new Date(ms + 8 * 3600e3).toISOString().slice(0, 19).replace('T', ' '); + if (!r.ok) { + modal('实测 ' + host, + '
握手失败:' + esc(r.error || '未知原因') + '
' + + '

如果这里是超时,也可能是对方 443 没开或者被墙 —— 不一定是证书问题。' + + 'Worker 从 Cloudflare 的边缘节点出去,和你在本地看到的未必一样。

' + + '
'); + return; + } + modal('实测 ' + host, + '
' + + '
剩余天数
' + + (r.daysLeft == null ? '—' : r.daysLeft + ' 天') + '
' + + '
到期时间
' + esc(r.notAfterText || '—') + '
' + + '
签发方
' + esc(r.issuer || '—') + '
' + + '
主体
' + esc(r.subject || '—') + '
' + + '
覆盖域名
' + esc((r.altNames || []).join(' · ') || '—') + '
' + + '
' + + (r.error ? '
' + + esc(r.error) + '
' : '') + + '
'); +} + // ============================================================ 事件委托 document.addEventListener('click', async (e) => { @@ -2098,6 +2505,84 @@ document.addEventListener('click', async (e) => { }); return; } + + // --- 证书管家 + if (act === 'ssl-check') { + el.dataset.busy = '1'; + try { + const r = await sslApi('/check', { method: 'POST' }); + const bad = (r.items || []).filter((i) => i.verdict !== 'match'); + toast(bad.length ? '⚠ ' + bad.length + ' 个域名需关注(见执行记录)' : '✓ 全部一致', !!bad.length); + await viewSSL(); + } catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; } + return; + } + if (act === 'ssl-notify') { + el.dataset.busy = '1'; + try { + const r = await sslApi('/notify', { method: 'POST' }); + toast('✓ 已发给 ' + r.sent + '/' + r.total + ' 个收件人'); + await loadSslLog(); + } catch (ex) { toast('✕ ' + ex.message, true); } + delete el.dataset.busy; + return; + } + if (act === 'ssl-notify-save') { + const emails = $('#sslNotifyEmails').value.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean); + const days = Number($('#sslNotifyDays').value); + el.dataset.busy = '1'; + try { + await sslApi('/config', { + method: 'POST', + body: { version: 1, notify: { emails, daysBefore: days }, domains: (state.ssl.ov.domains || []) }, + }); + toast('✓ 已保存'); await viewSSL(); + } catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; } + return; + } + if (act === 'ssl-add-domain') { await ensureAccessLoaded(); sslDomainModal(''); return; } + if (act === 'ssl-edit-domain') { await ensureAccessLoaded(); sslDomainModal(el.dataset.domain); return; } + if (act === 'ssl-del-domain') { + const dn = el.dataset.domain; + confirmBox('删除域名「' + dn + '」', + '只从配置里移除,不会去删线上证书,也不会动 DNS。库里的证书记录会变成「孤儿」(不影响使用)。确定?', + async () => { + const domains = (state.ssl.ov.domains || []).filter((d) => d.name !== dn); + await sslApi('/config', { method: 'POST', body: { version: 1, notify: state.ssl.ov.notify, domains } }); + toast('✓ 已删除'); await viewSSL(); + }); + return; + } + if (act === 'ssl-access-new') { sslAccessModal(''); return; } + if (act === 'ssl-access-edit') { await ensureAccessLoaded(); sslAccessModal(el.dataset.name); return; } + if (act === 'ssl-access-del') { + const an = el.dataset.name; + confirmBox('删除凭据「' + an + '」', '被域名配置引用的凭据删不掉(服务端会拦),删了就再也取不回密钥。确定?', + async () => { + await sslApi('/access/delete', { method: 'POST', body: { name: an } }); + toast('✓ 已删除'); await viewSSL(); + }); + return; + } + if (act === 'ssl-import') { sslImportModal(el.dataset.domain || ''); return; } + if (act === 'ssl-probe') { + const host = el.dataset.host; + if (!host) return; + el.dataset.busy = '1'; + try { + const r = await sslApi('/probe?host=' + encodeURIComponent(host)); + sslProbeModal(host, r); + } catch (ex) { toast('✕ ' + ex.message, true); } + delete el.dataset.busy; + return; + } + if (act === 'ssl-log-clear') { + confirmBox('清空日志', '只清 KV 里的执行记录,不影响证书和配置。确定?', async () => { + await sslApi('/log/clear', { method: 'POST' }); + toast('✓ 已清空'); await loadSslLog(); + }); + return; + } }); // ============================================================ 启动 @@ -2118,7 +2603,9 @@ $('#loginForm').addEventListener('submit', async (e) => { await doLogin($('#lgUser').value.trim(), $('#lgPass').value); state.me = state.me || {}; - if (!canEnterBackend()) throw new Error('该账号不是管理员或编辑,没有写作后台权限'); + if (!canEnterBackend() && !canUseCert()) { + throw new Error('该账号没有后台权限(需要管理员 / 编辑 / SSL 管理员)'); + } await bootstrap(); } catch (ex) { @@ -2187,16 +2674,18 @@ async function probeSession() { } catch (e) { return null; } } -/** 国内机模式:本机会话已就绪,直接进文章编辑(不经过 Cloudflare) */ +/** 国内机模式:本机会话已就绪,直接进自己有权限的那一页(不经过 Cloudflare) */ function enterDirect() { state.direct = true; if (!state.me) state.me = { name: 'admin', is_admin: true, role: 'admin' }; $('#gate').hidden = true; $('#app').hidden = false; $('#who').innerHTML = esc(state.me.name || 'admin') + - (isEditor() ? ' 编辑' : ''); + (isEditor() + ? ' 编辑' + : isSslOnly() ? ' SSL' : ''); $('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare'; - go('posts'); + go(isSslOnly() ? 'ssl' : 'posts'); } (async function boot() { @@ -2220,9 +2709,13 @@ function enterDirect() { const me = await api('/user'); if (!me || !me.user || !me.is_login) throw Object.assign(new Error('会话已失效'), { status: 401 }); state.me = me.user; - if (!canEnterBackend()) throw Object.assign(new Error('该账号不是管理员或编辑,没有写作后台权限'), { status: 403 }); + // 三种角色各有各的去处:编辑去文章、SSL 去证书、其余(普通用户)一律拒绝 + if (!canEnterBackend() && !canUseCert()) { + throw Object.assign(new Error('该账号没有后台权限(需要管理员 / 编辑 / SSL 管理员)'), { status: 403 }); + } localStorage.setItem(LS_U, JSON.stringify(state.me)); - await bootstrap(); + if (canEnterBackend()) await bootstrap(); + else showApp(); // SSL 管理员:跳过站点/评论那套预取(它没有权限,预取必 403) } catch (e) { logout(e.status ? '会话已失效,请重新登录' : ('连接失败:' + e.message)); } diff --git a/blog-admin/src/index.ts b/blog-admin/src/index.ts index 6e1d57c7..b0e67b8c 100644 --- a/blog-admin/src/index.ts +++ b/blog-admin/src/index.ts @@ -11,6 +11,7 @@ import * as U from './routes/user'; import * as A from './routes/admin'; import * as H from './routes/human'; import * as E from './routes/editor'; +import * as S from './routes/ssl'; const router = new Router(); @@ -47,6 +48,17 @@ router.post('/editor/git/publish', E.publish); router.post('/editor/git/sync', E.sync); router.get('/editor/handoff', E.handoff); +// ---------------------------------------------------------------- SSL 证书管家 +// 与 artalk-cf 同一个 Worker,共用后台会话与 KV。鉴权在自己的模块里 +// (只认 Bearer 会话,放行 admin + ssl 两种角色),这里只负责挂路径。 +// ★ 必须挂在 /api/v2 下 —— /api/*(非 v2)已被 RSS 模块整个接走。 +for (const r of S.SSL_ROUTES) { + if (r.method === 'GET') router.get(r.path, r.handler); + else if (r.method === 'POST') router.post(r.path, r.handler); + else if (r.method === 'PUT') router.put(r.path, r.handler); + else router.del(r.path, r.handler); +} + // ---------------------------------------------------------------- 评论 // ★ /comments/lookup 必须注册在 /comments/:id 之前 —— 路由按注册顺序匹配, // 两段路径下 `:id` 会把 "lookup" 吃掉。 diff --git a/blog-admin/src/lib/certprobe.ts b/blog-admin/src/lib/certprobe.ts new file mode 100644 index 00000000..207c3a4b --- /dev/null +++ b/blog-admin/src/lib/certprobe.ts @@ -0,0 +1,175 @@ +/** + * 证书探针 —— 从**外部**实测域名的证书状态。 + * + * 为什么不信 KV 里自己存的那份:KV 里的是「我们上次签发的」,而线上真实 + * 挂着的可能是别的 CA 签的、可能是手工换过的、可能部署失败了根本没上。 + * 只有真去握一次手,才知道读者看到的证书是什么。 + * + * 手段:`https:///.well-known/artalk-cf-probe` 发一次 HEAD 请求, + * Cloudflare Workers 的 `fetch` 走 TLS,我们可以从 `Response` 上拿不到 + * 证书 —— 所以换一条路:**用一个只回证书信息的第三方回显接口**并不存在, + * 于是这里退一步,只用 `fetch` 的成败 + `cf-*` 不可得的信息做「可达性」判断, + * 真正的证书详情(到期日/签发方)由 **KV 里的记录** + 手动上传/续期时的 + * 解析结果提供。 + * + * ★ 实际上 Workers **确实**提供了 `fetch` 的 TLS 信息:通过 + * `https://` 建立连接后,响应头里没有证书字段;但 Workers 的 + * `connect()` socket API 可以拿到 peer certificate。为保持零依赖与 + * 最小攻击面,这里用 socket 方案(见 probeTls)。 + */ + +export interface TlsInfo { + ok: boolean; + /** 实际协商到的 TLS 版本,如 TLSv1.3 */ + tlsVersion?: string; + /** 证书到期时间(毫秒时间戳) */ + notAfter?: number; + notBefore?: number; + subject?: string; + issuer?: string; + /** 证书覆盖的域名 */ + altNames?: string[]; + /** 失败原因(ok=false 时) */ + error?: string; + /** 是否在握手时就被拒绝(SNI 不匹配 / 证书过期) */ + handshakeFailed?: boolean; +} + +/** + * 用 Workers 的 TCP socket 直连 443 拉对端证书。 + * + * ★ 为什么不用 fetch:fetch 成功只说明 TLS 握手过了,拿不到证书正文, + * 而这正是证书管家最需要的东西(到期日、SAN、签发方)。 + * `cloudflare:sockets` 的 `connect()` 返回的对象上有 `getPeerCertificate()`, + * 是 Workers 里唯一能拿到证书的官方途径。 + * + * 注意:这个 API 在本地 `wrangler dev` 的部分版本里不可用,会抛错 —— + * 所以调用方必须能接受 `ok:false, error:'...'`,不能让它把整个接口带崩。 + */ +export async function probeTls(host: string, timeoutMs = 8000): Promise { + const h = String(host || '').trim().toLowerCase(); + if (!h || !/^[a-z0-9.*-]+$/.test(h)) return { ok: false, error: '域名不合法' }; + + let socket: { opened: Promise; close: () => void; getPeerCertificate?: () => unknown } | null = null; + try { + // 动态 import:本地 dev 环境没有这个模块时,不至于整个 Worker 起不来 + const mod = (await import('cloudflare:sockets')) as { + connect: (addr: { hostname: string; port: number }, opts?: { secureTransport?: string; allowHalfOpen?: boolean }) => unknown; + }; + const raw = mod.connect( + { hostname: h, port: 443 }, + { secureTransport: 'on', allowHalfOpen: false }, + ) as unknown as { + opened: Promise; + close: () => void; + getPeerCertificate?: () => unknown; + }; + socket = raw; + + const timeout = new Promise((_, rej) => + setTimeout(() => rej(new Error('握手超时')), timeoutMs), + ); + await Promise.race([raw.opened, timeout]); + + const certOf = raw.getPeerCertificate; + if (typeof certOf !== 'function') { + raw.close(); + return { ok: true, handshakeFailed: false, error: '当前运行时拿不到证书正文(只确认了可达)' }; + } + const cert = certOf.call(raw) as { + notAfter?: number | string; + notBefore?: number | string; + subject?: string; + issuer?: string; + subjectaltname?: string; + } | null; + raw.close(); + if (!cert) return { ok: false, error: '对端没返回证书' }; + + return { + ok: true, + notAfter: toMs(cert.notAfter), + notBefore: toMs(cert.notBefore), + subject: String(cert.subject || ''), + issuer: String(cert.issuer || ''), + altNames: parseAltNames(String(cert.subjectaltname || '')), + }; + } catch (e) { + try { + socket?.close(); + } catch { + /* 已经关了 */ + } + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: msg, handshakeFailed: /handshake|tls|certificate/i.test(msg) }; + } +} + +function toMs(v: number | string | undefined): number | undefined { + if (v == null) return undefined; + if (typeof v === 'number') return v; + const t = Date.parse(v); + return Number.isFinite(t) ? t : undefined; +} + +/** `DNS:usj.cc, DNS:*.usj.cc` → `['usj.cc','*.usj.cc']` */ +function parseAltNames(s: string): string[] { + return s + .split(',') + .map((x) => x.trim().replace(/^DNS:/i, '')) + .filter(Boolean); +} + +/** + * 从 PEM 证书正文里读到期日 / SAN。 + * ACME 签发完、或者用户手工粘贴一张证书时用它落库(不用再连一次网络)。 + * + * 实现上走 `crypto.subtle` 的 X.509 解析没有标准 API, + * 所以这里只做**最小解析**:从 base64 DER 里定位 UTCTime/GeneralizedTime。 + * 够用且不引依赖 —— 真正的权威判断还是 probeTls 的实测。 + */ +export function parsePemInfo(pem: string): { notAfter?: number; altNames?: string[] } { + const b64 = String(pem || '') + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/\s+/g, ''); + if (!b64) return {}; + let der: string; + try { + der = atob(b64); + } catch { + return {}; + } + // 取最后一个「看起来像时间」的序列:证书里 validity 是 notBefore / notAfter 一对, + // 直接按 ASN.1 精解太重,这里取两个 UTC/Generalized 时间里的后一个。 + const times: number[] = []; + const re = /(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/g; + const reG = /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/g; + for (const m of der.matchAll(re)) { + const y = 2000 + Number(m[1]); + times.push(Date.UTC(y, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]))); + } + for (const m of der.matchAll(reG)) { + times.push( + Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])), + ); + } + // SAN 的 DNS 名字在 DER 里是可见 ASCII(标签 0x82 后跟长度) + const alt: string[] = []; + const san = der.match(/[a-z0-9*.-]+\.[a-z]{2,}/gi) || []; + for (const s of san) { + const v = s.toLowerCase(); + if (!alt.includes(v) && !v.includes('..')) alt.push(v); + } + const sorted = times.filter((t) => t > Date.UTC(2000, 0, 1)).sort((a, b) => a - b); + return { + notAfter: sorted.length ? sorted[sorted.length - 1] : undefined, + altNames: alt.slice(0, 20), + }; +} + +/** 剩余天数(向上取整;已过期返回负数) */ +export function daysLeft(expireAt: number | undefined, at = Date.now()): number | null { + if (!expireAt) return null; + return Math.ceil((expireAt - at) / 86400000); +} diff --git a/blog-admin/src/lib/certstore.ts b/blog-admin/src/lib/certstore.ts new file mode 100644 index 00000000..9c03bbb9 --- /dev/null +++ b/blog-admin/src/lib/certstore.ts @@ -0,0 +1,306 @@ +/** + * 证书管家的**唯一数据入口** —— 所有 KV 读写都收在这里。 + * + * 为什么不直接在路由里 `env.RSS_KV.get(...)`:证书管家的数据分四类 + * (域名配置 / 凭据 / 证书 / 日志),每类的加密要求、缺省值、迁移逻辑都不一样。 + * 散在路由里写,迟早出现「某处读配置忘了兜默认值 → 线上配置被空对象覆盖」。 + * + * KV 键位(复用已有的 RSS_KV namespace,不新建 —— 免费额度共用一份就够): + * certkeeper:config 域名配置 + 通知设置 明文(无敏感信息) + * certkeeper:access: 单条凭据 ★ AES-GCM 密文 + * certkeeper:cert: 签发的证书与私钥 ★ AES-GCM 密文 + * certkeeper:log 最近 N 条执行记录 明文 + * + * ★ 为什么凭据**一条一键**,不合成一个大对象: + * KV 的「读-改-写」没有事务。合成一个对象意味着「改 A 的凭据」要 + * 读全量 → 改 → 写回全量,两个管理员同时改不同凭据必然丢一个。 + * 一条一键的最坏情况是「同一把凭据被同时改」。更关键的是:不返回明文的 + * 凭据列表接口**根本不需要把私钥读进内存**,泄露面小一个数量级。 + */ +import type { Env } from '../types'; +import { isSealed, maskSecret, openJson, peekJson, sealJson } from './certvault'; + +const P = 'certkeeper:'; + +/** 列 key(KV list 分页),只回名字 */ +async function listKeys(env: Env, prefix: string): Promise { + const out: string[] = []; + let cursor: string | undefined; + for (;;) { + const page = await env.RSS_KV.list({ prefix: P + prefix, cursor }); + for (const k of page.keys) out.push(k.name.slice((P + prefix).length)); + // ★ KVNamespaceListResult 是「完成/未完成」的联合类型,只有未完成那支才有 + // cursor 字段。写成 `cursor = page.list_complete ? undefined : page.cursor` + // 收窄不了(三元表达式里 page 不会按分支收窄),必须用 if 让 TS 认得出来。 + if (page.list_complete) break; + cursor = page.cursor; + } + return out.sort(); +} + +// ==================================================================== 类型 + +/** 单条凭据的类型标签 —— 决定加密之外还有哪些必填字段 */ +export type AccessType = + | 'tencentcloud' // 腾讯云 CAM(DNSPod DNS-01 用) + | 'cloudflare' // CF API Token(DNS + 部署) + | 'dogecloud' // 多吉云 AK/SK(CDN 证书部署) + | '1panel' // 1Panel 面板 API + | 'acme-eab'; // ACME External Account Binding(LiteSSL / ZeroSSL) + +export interface AccessRecord { + type: AccessType; + /** 人类可读的备注,列表里显示 */ + note?: string; + [k: string]: unknown; +} + +export interface DomainConfig { + name: string; + san: string[]; + /** 引用凭据名(不是凭据本身),见 AccessRecord */ + dns: string; + /** 部署目标 */ + deploy: string[]; + dogecloud_domains?: string[]; + one_panel_sites?: string[]; + /** 关掉这个域名的一切自动动作(保留配置,只是不跑) */ + disabled?: boolean; +} + +export interface NotifyConfig { + emails: string[]; + /** 剩余多少天开始提醒 */ + daysBefore: number; +} + +export interface KeeperConfig { + /** ★ 前向兼容:加字段时递增,读取侧按版本迁移 */ + version: number; + notify: NotifyConfig; + domains: DomainConfig[]; +} + +export interface CertRecord { + cert: string; + key: string; + /** 到期时间(毫秒时间戳) */ + expireAt: number; + /** 签发时间 */ + updatedAt: number; + issuer?: string; + /** 覆盖的域名(从证书里解析) */ + san?: string[]; +} + +export interface LogEntry { + at: number; + level: 'info' | 'warn' | 'error'; + /** 动作:check / renew / deploy / notify */ + action: string; + domain?: string; + message: string; +} + +export const CONFIG_VERSION = 1; + +const DEFAULT_CONFIG: KeeperConfig = { + version: CONFIG_VERSION, + notify: { emails: [], daysBefore: 30 }, + domains: [], +}; + +const LOG_MAX = 200; + +// ==================================================================== 配置 + +/** + * 读配置。 + * ★ 读失败(KV 抖动 / 密文格式不对)时**抛错**,不要 return 默认值 —— + * 调用方拿到「空配置」之后一路正常,接着一个保存就把它写回去了。 + * 「读不到」和「本来就是空」必须区分开。 + */ +export async function loadConfig(env: Env): Promise { + const raw = await env.RSS_KV.get(P + 'config'); + if (!raw) return { ...DEFAULT_CONFIG, notify: { ...DEFAULT_CONFIG.notify }, domains: [] }; + let parsed: KeeperConfig; + try { + parsed = JSON.parse(raw) as KeeperConfig; + } catch { + throw new Error('证书配置不是合法 JSON(KV 里可能被写坏了)'); + } + return migrateConfig(parsed); +} + +/** 老版本配置 → 当前版本。每次加字段都在这里补一次默认值 */ +function migrateConfig(c: KeeperConfig): KeeperConfig { + const out: KeeperConfig = { + version: CONFIG_VERSION, + notify: { + emails: Array.isArray(c?.notify?.emails) ? c.notify.emails.filter(Boolean) : [], + daysBefore: Number(c?.notify?.daysBefore) > 0 ? Number(c.notify.daysBefore) : 30, + }, + domains: Array.isArray(c?.domains) ? c.domains.map(normalizeDomain) : [], + }; + return out; +} + +function normalizeDomain(d: DomainConfig): DomainConfig { + return { + name: String(d?.name || '').trim().toLowerCase(), + san: Array.isArray(d?.san) ? d.san.map((s) => String(s).trim().toLowerCase()).filter(Boolean) : [], + dns: String(d?.dns || '').trim(), + deploy: Array.isArray(d?.deploy) ? d.deploy.map((s) => String(s).trim()).filter(Boolean) : [], + ...(d?.dogecloud_domains ? { dogecloud_domains: d.dogecloud_domains.map(String) } : {}), + ...(d?.one_panel_sites ? { one_panel_sites: d.one_panel_sites.map(String) } : {}), + ...(d?.disabled ? { disabled: true } : {}), + }; +} + +export async function saveConfig(env: Env, cfg: KeeperConfig): Promise { + const clean = migrateConfig(cfg); + await env.RSS_KV.put(P + 'config', JSON.stringify(clean)); + return clean; +} + +// ==================================================================== 凭据 + +export async function putAccess(env: Env, name: string, rec: AccessRecord): Promise { + const key = String(name || '').trim(); + if (!key) throw new Error('凭据名不能为空'); + await env.RSS_KV.put(P + 'access:' + key, await sealJson(env, rec)); +} + +/** 取凭据明文。**只给真正要调用外部 API 的代码用**,不要往 HTTP 响应里塞 */ +export async function getAccess(env: Env, name: string): Promise { + const raw = await env.RSS_KV.get(P + 'access:' + String(name || '').trim()); + if (!raw) return null; + if (!isSealed(raw)) { + // 兼容:如果之前手工塞过明文 JSON(调试期),也认,但下次保存会转成密文 + try { + return JSON.parse(raw) as AccessRecord; + } catch { + return null; + } + } + return openJson(env, raw); +} + +export async function delAccess(env: Env, name: string): Promise { + await env.RSS_KV.delete(P + 'access:' + String(name || '').trim()); +} + +/** + * 列出所有凭据的**脱敏摘要**。 + * + * ★ 返回值里**没有明文密钥**:只保留 type / note 这类非敏感字段, + * 以及各个密钥字段的「尾 4 位」(如 `AKID****3f2a`),让管理员认得出 + * 是哪把钥匙,又拿不到完整值。要改就是整条重填。 + * + * ★ 为什么不像注释里说的那样「不解密」:type 和 note 本身就在密文里。 + * 这里必须逐个解密,但**只在内存里存在一瞬**,且返回值是脱敏结构 —— + * 比解密后原样吐给浏览器安全得多。唯一真正的替代方案是把 type/note + * 另存一份明文,那样反而多一处不一致的来源。 + */ +export async function listAccess(env: Env): Promise< + { name: string; type: string; note: string; fields: Record; unreadable?: boolean }[] +> { + const names = await listKeys(env, 'access:'); + const out: { + name: string; + type: string; + note: string; + fields: Record; + unreadable?: boolean; + }[] = []; + + for (const name of names) { + const raw = await env.RSS_KV.get(P + 'access:' + name); + if (!raw) continue; + const rec = isSealed(raw) + ? await peekJson(env, raw) + : (() => { + try { + return JSON.parse(raw) as AccessRecord; + } catch { + return null; + } + })(); + if (!rec) { + // 解不开也要列出来 —— 否则密钥轮换后管理员看不到「这里还有一条凭据」, + // 会以为是空的,然后新建一条重名覆盖。 + out.push({ name, type: '', note: '', fields: {}, unreadable: true }); + continue; + } + const fields: Record = {}; + for (const [k, v] of Object.entries(rec)) { + if (k === 'type' || k === 'note') continue; + if (typeof v === 'string' || typeof v === 'number') fields[k] = maskSecret(v); + } + out.push({ name, type: String(rec.type || ''), note: String(rec.note || ''), fields }); + } + return out; +} + +// ==================================================================== 证书 + +export async function putCert(env: Env, domain: string, rec: CertRecord): Promise { + const key = String(domain || '').trim().toLowerCase(); + if (!key) throw new Error('域名不能为空'); + await env.RSS_KV.put(P + 'cert:' + key, await sealJson(env, rec)); +} + +export async function getCert(env: Env, domain: string): Promise { + const raw = await env.RSS_KV.get(P + 'cert:' + String(domain || '').trim().toLowerCase()); + if (!raw) return null; + if (!isSealed(raw)) return null; + try { + return await openJson(env, raw); + } catch { + // 密钥换了之后老密文解不开。当作「没有这张证书」比让整个列表接口 500 好, + // 上层看到「无证书」会提示重签,实际也是对的处置。 + return null; + } +} + +export async function delCert(env: Env, domain: string): Promise { + await env.RSS_KV.delete(P + 'cert:' + String(domain || '').trim().toLowerCase()); +} + +export async function listCertNames(env: Env): Promise { + return listKeys(env, 'cert:'); +} + +// ==================================================================== 日志 + +/** + * 追加一条执行日志(环形缓冲,最多 200 条)。 + * + * ★ 这个函数是**并发不安全**的(读-改-写)。但日志丢一条无关紧要, + * 绝不能为了它引入锁或者 D1 表 —— 证书续期一天才跑一次。 + * 真正的账要记在「证书本身」上(cert: 的 updatedAt),日志只做参考。 + */ +export async function appendLog(env: Env, entry: LogEntry): Promise { + try { + const cur = await loadLog(env); + cur.unshift(entry); + await env.RSS_KV.put(P + 'log', JSON.stringify(cur.slice(0, LOG_MAX))); + } catch (e) { + console.error('[certkeeper] 写日志失败:', e instanceof Error ? e.message : e); + } +} + +export async function loadLog(env: Env): Promise { + const raw = await env.RSS_KV.get(P + 'log'); + if (!raw) return []; + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? (v as LogEntry[]) : []; + } catch { + return []; + } +} + +export async function clearLog(env: Env): Promise { + await env.RSS_KV.delete(P + 'log'); +} diff --git a/blog-admin/src/lib/certvault.ts b/blog-admin/src/lib/certvault.ts new file mode 100644 index 00000000..947dc7f8 --- /dev/null +++ b/blog-admin/src/lib/certvault.ts @@ -0,0 +1,126 @@ +/** + * 证书管家的「保险箱」—— 用 TOKEN_SECRET 派生密钥做 AES-GCM 加解密。 + * + * 为什么必须有这一层:KV 里要存**私钥**和**云厂商 AK/SK**。 + * KV 本身是明文的,一旦某个有 KV 读权限的 token 泄露,等于把 TLS 私钥 + * 和腾讯云 CAM 密钥一起送出去。加密之后泄露的只是密文。 + * + * 设计取舍: + * · 密钥不另外配 secret —— 复用 TOKEN_SECRET(已经是 wrangler secret)。 + * 多一个 secret 就多一处「忘了配 → 线上 500」的坑。 + * · 用 PBKDF2 从 TOKEN_SECRET 派生,而不是直接拿它当 AES key: + * TOKEN_SECRET 是给人看的字符串,长度/熵都不确定;派生一步拿到确定的 + * 256 bit,也顺手把「同密钥别跨用途复用」这件事做干净。 + * · 每条密文带**自己的随机 IV**(12 字节 GCM 标准长度),拼成 + * `v1..`。带版本前缀是为了将来换算法时能识别老数据。 + * + * ★ 别把这里的东西拿去加密用户密码 —— 那是 PBKDF2 单向哈希的活(见 session.ts)。 + */ +import type { Env } from '../types'; + +const VERSION = 'v1'; +const IV_BYTES = 12; +const SALT = 'artalk-cf:certvault:v1'; // 固定盐:只要密钥不变就能解开历史密文 + +function b64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function unb64(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +async function vaultKey(env: Env): Promise { + const secret = String(env.TOKEN_SECRET || ''); + if (!secret) throw new Error('TOKEN_SECRET 未配置,保险箱无法工作'); + const base = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + 'PBKDF2', + false, + ['deriveKey'], + ); + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: new TextEncoder().encode(SALT), + iterations: 100000, + }, + base, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +/** 明文对象 → `v1..` 密文字符串(可直接落 KV) */ +export async function sealJson(env: Env, value: unknown): Promise { + const key = await vaultKey(env); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const pt = new TextEncoder().encode(JSON.stringify(value ?? null)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as unknown as BufferSource }, + key, + pt as unknown as BufferSource, + ); + return `${VERSION}.${b64(iv)}.${b64(new Uint8Array(ct))}`; +} + +/** + * 密文字符串 → 明文对象。 + * 解不开(密钥换了 / 数据被改 / 格式不对)一律抛错 —— **绝不返回半成品**, + * 否则调用方会拿一个空对象当「配置为空」把线上配置覆盖掉。 + */ +export async function openJson(env: Env, sealed: string): Promise { + const parts = String(sealed || '').split('.'); + if (parts.length !== 3 || parts[0] !== VERSION) { + throw new Error('密文格式不认识(可能来自更早的版本)'); + } + const key = await vaultKey(env); + const iv = unb64(parts[1]); + const ct = unb64(parts[2]); + const pt = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv as unknown as BufferSource }, + key, + ct as unknown as BufferSource, + ); + return JSON.parse(new TextDecoder().decode(pt)) as T; +} + +/** 是不是本模块产出的密文(判断 KV 里存的是明文老数据还是密文) */ +export function isSealed(v: unknown): boolean { + return typeof v === 'string' && v.startsWith(VERSION + '.') && v.split('.').length === 3; +} + +/** + * 读**明文摘要**:密文解不开时也要能列出「有哪些凭据」,不能整个接口 500。 + * 返回 null 表示这条读不出来(密文格式不认识 / 密钥已轮换)。 + * + * ★ 注意与 openJson 的区别:openJson 解不开就抛(用于**要拿值去用**的场景), + * 这里解不开返回 null(用于**只看名字和类型**的场景)。混用会很危险。 + */ +export async function peekJson(env: Env, sealed: string): Promise { + try { + return await openJson(env, sealed); + } catch { + return null; + } +} + +/** + * 脱敏:只保留前 4 位 + 后 4 位,中间打星。 + * 用于「凭据列表」接口 —— 要让用户认得出是哪把钥匙,又不能让他(或者 + * 偷看他屏幕的人)拿到完整值。 + */ +export function maskSecret(v: unknown): string { + const s = String(v ?? ''); + if (!s) return ''; + if (s.length <= 8) return '*'.repeat(s.length); + return s.slice(0, 4) + '*'.repeat(Math.min(8, s.length - 8)) + s.slice(-4); +} diff --git a/blog-admin/src/lib/role.ts b/blog-admin/src/lib/role.ts index 35522bbb..c07d6c9d 100644 --- a/blog-admin/src/lib/role.ts +++ b/blog-admin/src/lib/role.ts @@ -7,10 +7,12 @@ * * 两处事实源的关系: * · `users.is_admin` 是历史字段,评论/通知/设置等一大堆既有逻辑都在用它 —— 不动。 - * · `users.role` 是这次新增的('' 普通评论用户 | 'editor' 编辑 | 'admin' 管理员), - * 只有它才能表达「编辑」这一级。 + * · `users.role` 是这次新增的('' 普通评论用户 | 'editor' 编辑 | 'admin' 管理员 + * | 'ssl' SSL 管理员),只有它才能表达「编辑」「SSL」这两级。 * · 两者必须同步:role='admin' ⇔ is_admin=1。写入走 `normalizeRole()`, * 读取走 `roleOf()`(以 is_admin 优先,兜住没同步过的历史行)。 + * · ★ 'ssl' 是**平级**角色而不是某种编辑:它只拿证书管家的钥匙, + * 写作后台、评论管理一个都进不去。判定要正着写,别用「非 admin 即放行」。 */ import type { UserRole } from '../types'; @@ -21,20 +23,22 @@ export interface RoleCarrier { } /** - * 归一化读取:只认三种结果。 + * 归一化读取:只认四种结果。 * ★ is_admin 优先 —— 历史行可能没有 role 列的值(ALTER 之前建的账号), * 但 is_admin=1 是确定的,不能因为 role 是空串就把老管理员降级。 */ export function roleOf(u: RoleCarrier | null | undefined): UserRole { if (!u) return 'user'; if (u.is_admin) return 'admin'; - return String(u.role || '') === 'editor' ? 'editor' : 'user'; + const r = String(u.role || ''); + if (r === 'editor' || r === 'ssl') return r; + return 'user'; } /** 写入方向:把前端传来的一对(role / is_admin)折叠成一个规范值 */ -export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'editor' | '' { +export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'editor' | 'ssl' | '' { const r = String(role ?? '').trim().toLowerCase(); - if (r === 'admin' || r === 'editor') return r; + if (r === 'admin' || r === 'editor' || r === 'ssl') return r; if (r === '' && isAdmin === undefined) return ''; // 没给 role(老前端只给 is_admin)时按 is_admin 推 if (role === undefined || role === null) return isAdmin ? 'admin' : ''; @@ -42,12 +46,25 @@ export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'edito return isAdmin ? 'admin' : ''; } -/** 能进写作后台的角色(管理员 + 编辑) */ +/** 能进写作后台的角色(管理员 + 编辑)—— SSL 管理员**不在其列** */ export function canWritePosts(u: RoleCarrier | null | undefined): boolean { const r = roleOf(u); return r === 'admin' || r === 'editor'; } +/** + * 能开证书管家后台的角色(管理员 + SSL 管理员)。 + * + * ★ 判定必须**正着写**(枚举放行),不要写成 `role !== 'editor'`。 + * 血泪教训同 editor.ts 里的 isAdminRequest 兜底:一旦用了「排除法」, + * 以后新增任何角色都会静默获得证书管家的权限 —— 而这里能拿到 + * TLS 私钥和云厂商 AK/SK,越权的代价太大。 + */ +export function canManageSSL(u: RoleCarrier | null | undefined): boolean { + const r = roleOf(u); + return r === 'admin' || r === 'ssl'; +} + /** 编辑角色在文章维度上只能碰自己的(管理员不受限) */ export function isScopedEditor(u: RoleCarrier | null | undefined): boolean { return roleOf(u) === 'editor'; @@ -55,5 +72,8 @@ export function isScopedEditor(u: RoleCarrier | null | undefined): boolean { /** 给前端展示的角色名(后台用户管理列表用) */ export function roleLabel(role: UserRole): string { - return role === 'admin' ? '管理员' : role === 'editor' ? '编辑' : ''; + if (role === 'admin') return '管理员'; + if (role === 'editor') return '编辑'; + if (role === 'ssl') return 'SSL 管理员'; + return ''; } diff --git a/blog-admin/src/routes/ssl.ts b/blog-admin/src/routes/ssl.ts new file mode 100644 index 00000000..87db6cd5 --- /dev/null +++ b/blog-admin/src/routes/ssl.ts @@ -0,0 +1,633 @@ +/** + * SSL 证书管家 —— Worker 侧的全部 HTTP 接口。 + * + * ★★ 本文件是「谁能碰证书」的唯一闸门,动它之前先读完下面两段。 + * + * 一、鉴权:**只认真实登录会话(Bearer token)**,绝不能用 isAdminRequest。 + * 理由与 routes/editor.ts 完全一致:isAdminRequest 里有一条 Artalk 为老 + * 客户端留的兜底 —— 请求带 `?name=<管理员名>&email=<管理员邮箱>` 就视为管理员, + * 而这两个值是写死在 wrangler.toml、并暴露在后台页面里的公开信息。 + * 一旦这条兜底泄漏到这个文件,任何人拼个 query 就能读走 **TLS 私钥**。 + * + * 二、角色:放行 admin + ssl(见 lib/role.ts 的 canManageSSL)。 + * ★ 判定必须正着写(枚举放行)。写成 `role !== 'editor'` 这类排除法, + * 以后每加一个角色都会静默获得证书权限 —— 而这里握着私钥和云厂商 AK/SK。 + * + * 路径挂在 /api/v2/ssl/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`, + * 而 /api/*(非 v2)已经被 RSS 模块整个接走(见 src/index.ts),所以必须走 v2 前缀。 + */ +import type { Env, UserRow } from '../types'; +import type { Ctx } from '../router'; +import { fail, isEmail, json, now, ok, readBody, trimTo } from '../lib/util'; +import { userFromToken } from '../lib/session'; +import { canManageSSL, roleOf } from '../lib/role'; +import { getAdminUsers } from '../lib/db'; +import { + appendLog, + clearLog, + delAccess, + delCert, + getCert, + listAccess, + listCertNames, + loadConfig, + loadLog, + putAccess, + putCert, + saveConfig, + type AccessRecord, + type AccessType, + type DomainConfig, + type KeeperConfig, +} from '../lib/certstore'; +import { daysLeft, parsePemInfo, probeTls } from '../lib/certprobe'; +import { mailEnabled, sendMail } from '../lib/mail'; +import { formatDateCN } from '../lib/util'; + +const ACCESS_TYPES: AccessType[] = ['tencentcloud', 'cloudflare', 'dogecloud', '1panel', 'acme-eab']; +const DEPLOY_TARGETS = ['dogecloud', '1panel', 'tencentcloud-eo']; + +// ==================================================================== 鉴权 + +interface SslIdentity { + id: number; + name: string; + role: 'admin' | 'ssl'; +} + +/** + * 解析操作者。返回 null = 不是管理员也不是 SSL 管理员。 + * 与 editor.ts 的 requireEditorSession 逐字同构,只换角色判定 —— + * **刻意不抽公共函数**:两处的「兜底」语义将来很可能分化 + * (编辑要邮箱兜底认老管理员,证书这边绝不要),共享一个函数会更危险。 + */ +async function requireSslSession(ctx: Ctx): Promise { + const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization'))); + if (!user) return null; + + const role = roleOf(user); + if (role === 'admin') return { id: user.id, name: user.name, role: 'admin' }; + if (role === 'ssl') return { id: user.id, name: user.name, role: 'ssl' }; + + // 已登录但没打 is_admin 标的老管理员账号:邮箱命中配置里的管理员也算。 + // ★ 只在「本来就有 users 行、且邮箱是后台管理员邮箱」时生效 —— + // 攻击者拿不到这个前提(他得先有一条能登录的用户行,还要邮箱正好对上)。 + const admins = await getAdminUsers(ctx.env); + if (admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase())) { + return { id: user.id, name: user.name, role: 'admin' }; + } + return null; +} + +async function auth(ctx: Ctx): Promise<{ ident: SslIdentity } | { deny: Response }> { + const ident = await requireSslSession(ctx); + if (!ident) return { deny: fail(403, '需要管理员或 SSL 管理员权限') }; + return { ident }; +} + +/** 写操作额外校验 Origin,防 CSRF(读操作不做,免得把只读接口也搞脆) */ +function checkOrigin(ctx: Ctx): Response | null { + const origin = ctx.req.headers.get('Origin'); + if (!origin) return null; // 同源 fetch 在部分浏览器不带 Origin,放过 + const allow = (ctx.env.ALLOWED_ORIGINS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + let host = ''; + try { + host = new URL(origin).host; + } catch { + return fail(403, 'Origin 不合法'); + } + const selfHost = new URL(ctx.req.url).host; + if (host === selfHost) return null; + if (allow.some((a) => a === '*' || a.includes(host))) return null; + return fail(403, '拒绝跨站写入(Origin 不在白名单)'); +} + +/** 写操作统一入口:鉴权 + Origin + 读 body */ +async function writeAuth( + ctx: Ctx, +): Promise<{ ident: SslIdentity; body: Record } | { deny: Response }> { + const a = await auth(ctx); + if ('deny' in a) return a; + const csrf = checkOrigin(ctx); + if (csrf) return { deny: csrf }; + const body = await readBody(ctx.req); + return { ident: a.ident, body }; +} + +/** 写日志的语法糖(带上操作者,方便追责) */ +async function log( + ctx: Ctx, + ident: SslIdentity, + action: string, + message: string, + level: 'info' | 'warn' | 'error' = 'info', + domain?: string, +): Promise { + await appendLog(ctx.env, { at: now(), level, action, domain, message: `${ident.name}: ${message}` }); +} + +// ==================================================================== 概览 + +/** + * 证书总览 —— 后台首屏用。 + * 每个域名给:配置 / 库里的记录 / **实测**状态三份信息,前端能一眼看出 + * 「配了没」「有证没」「线上挂的到底是不是这张」。 + */ +export async function overview(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + + const cfg = await loadConfig(ctx.env); + const certNames = await listCertNames(ctx.env); + + const rows = await Promise.all( + cfg.domains.map(async (d) => { + const rec = await getCert(ctx.env, d.name); + const left = daysLeft(rec?.expireAt); + return { + name: d.name, + san: d.san, + dns: d.dns, + deploy: d.deploy, + disabled: !!d.disabled, + hasCert: !!rec, + expireAt: rec?.expireAt || null, + expireText: rec?.expireAt ? formatDateCN(rec.expireAt) : null, + issuer: rec?.issuer || '', + updatedAt: rec?.updatedAt || null, + daysLeft: left, + level: levelOf(left, cfg.notify.daysBefore, !!d.disabled), + }; + }), + ); + + // 库里有、配置里没有的证书(删域名时留下的孤儿)也列出来,免得悄悄占着空间 + const orphans = certNames.filter((n) => !cfg.domains.some((d) => d.name === n)); + + return ok({ + domains: rows, + orphans, + notify: cfg.notify, + mailEnabled: mailEnabled(ctx.env), + serverTime: formatDateCN(now()), + }); +} + +/** 把「剩余天数」翻译成前端要用的颜色档位 */ +function levelOf(days: number | null, warnDays: number, disabled: boolean): 'ok' | 'warn' | 'danger' | 'none' { + if (disabled) return 'none'; + if (days == null) return 'none'; + if (days < 0) return 'danger'; + if (days <= warnDays) return 'warn'; + return 'ok'; +} + +// ==================================================================== 域名配置 + +export async function configGet(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + return ok(await loadConfig(ctx.env)); +} + +/** + * 保存整份域名配置。 + * + * ★ 这里做**完整校验**而不是信任前端:配置错了的后果是「下次续期时写到 + * 错误的 DNS 记录 / 把证书部署到别的站点」,而且往往 90 天后才发现。 + * 宁可在保存时就报错。 + */ +export async function configSave(ctx: Ctx): Promise { + const w = await writeAuth(ctx); + if ('deny' in w) return w.deny; + const { body, ident } = w; + + const domainsRaw = Array.isArray(body.domains) ? body.domains : null; + if (!domainsRaw) return fail(400, 'domains 必须是数组'); + + const seen = new Set(); + const domains: DomainConfig[] = []; + for (const [i, d] of domainsRaw.entries()) { + const name = String(d?.name || '').trim().toLowerCase(); + if (!name) return fail(400, `第 ${i + 1} 个域名缺少 name`); + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(name.replace(/^\*\./, ''))) { + return fail(400, `「${name}」不像一个域名`); + } + if (seen.has(name)) return fail(400, `域名「${name}」重复了`); + seen.add(name); + + const san = Array.isArray(d?.san) ? d.san.map((s: unknown) => String(s).trim().toLowerCase()).filter(Boolean) : []; + if (!san.length) return fail(400, `「${name}」至少要有一个 SAN(填域名本身也行)`); + + const dns = String(d?.dns || '').trim(); + if (!dns) return fail(400, `「${name}」没指定 DNS 凭据`); + if (!(await hasAccess(ctx.env, dns))) return fail(400, `「${name}」引用的 DNS 凭据「${dns}」不存在`); + + const deploy = Array.isArray(d?.deploy) ? d.deploy.map((s: unknown) => String(s).trim()).filter(Boolean) : []; + for (const t of deploy) { + if (!DEPLOY_TARGETS.includes(t)) return fail(400, `不认识的部署目标「${t}」`); + } + + domains.push({ + name, + san, + dns, + deploy, + dogecloud_domains: Array.isArray(d?.dogecloud_domains) ? d.dogecloud_domains.map(String).filter(Boolean) : [], + one_panel_sites: Array.isArray(d?.one_panel_sites) ? d.one_panel_sites.map(String).filter(Boolean) : [], + ...(d?.disabled ? { disabled: true } : {}), + }); + } + + const notifyEmails = Array.isArray(body?.notify?.emails) + ? body.notify.emails.map((s: unknown) => String(s).trim()).filter(Boolean) + : []; + for (const e of notifyEmails) { + if (!isEmail(e)) return fail(400, `通知邮箱「${e}」格式不对`); + } + const daysBefore = Number(body?.notify?.daysBefore); + if (!Number.isFinite(daysBefore) || daysBefore < 1 || daysBefore > 365) { + return fail(400, '提前提醒天数要在 1–365 之间'); + } + + const saved = await saveConfig(ctx.env, { + version: Number(body?.version) || 1, + notify: { emails: notifyEmails, daysBefore: Math.floor(daysBefore) }, + domains, + }); + await log(ctx, ident, 'config', `保存配置:${domains.length} 个域名,提醒邮箱 ${notifyEmails.length} 个`); + return ok(saved); +} + +async function hasAccess(env: Env, name: string): Promise { + return (await env.RSS_KV.get('certkeeper:access:' + name)) !== null; +} + +// ==================================================================== 凭据 + +export async function accessList(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + return ok({ items: await listAccess(ctx.env), types: ACCESS_TYPES }); +} + +/** + * 保存凭据。 + * ★ 允许「只改备注」:body.fields 为空且这是已存在的凭据时,保留原密文。 + * 否则管理员想给凭据加个说明,就得把整串密钥重新填一遍。 + */ +export async function accessSave(ctx: Ctx): Promise { + const w = await writeAuth(ctx); + if ('deny' in w) return w.deny; + const { body, ident } = w; + + const name = trimTo(String(body.name || '').trim(), 60); + if (!name) return fail(400, '凭据名不能为空'); + if (!/^[A-Za-z0-9._-]+$/.test(name)) return fail(400, '凭据名只能用字母、数字、点、下划线、短横线'); + + const type = String(body.type || '').trim() as AccessType; + if (!ACCESS_TYPES.includes(type)) return fail(400, `不认识的凭据类型「${type}」`); + + const fields = body.fields && typeof body.fields === 'object' ? body.fields : {}; + const cleaned: Record = {}; + for (const [k, v] of Object.entries(fields)) { + const key = String(k).trim(); + const val = String(v ?? '').trim(); + // 前端回显的是脱敏值(AKID****3f2a),管理员没改它时别把星号存进去 + if (val && !/^\*+$/.test(val) && !val.includes('****')) cleaned[key] = val; + } + + const existed = await ctx.env.RSS_KV.get('certkeeper:access:' + name); + if (!Object.keys(cleaned).length) { + if (!existed) return fail(400, '新建凭据必须填至少一个密钥字段'); + // 只更新备注:读旧值 → 改 note → 写回 + const raw = existed; + const old = await readAccessRaw(ctx.env, name); + if (!old) return fail(409, '原凭据读不出来(密钥可能已轮换),请整条重填'); + await putAccess(ctx.env, name, { ...old, type, note: trimTo(String(body.note || ''), 120) }); + await log(ctx, ident, 'access', `更新凭据「${name}」的说明`); + return ok({ name, updated: true }); + } + + const rec: AccessRecord = { type, note: trimTo(String(body.note || ''), 120), ...cleaned }; + await putAccess(ctx.env, name, rec); + await log(ctx, ident, 'access', `${existed ? '更新' : '新建'}凭据「${name}」(${type})`); + return ok({ name }); +} + +async function readAccessRaw(env: Env, name: string): Promise { + const raw = await env.RSS_KV.get('certkeeper:access:' + name); + if (!raw) return null; + const { isSealed, openJson } = await import('../lib/certvault'); + if (!isSealed(raw)) { + try { + return JSON.parse(raw) as AccessRecord; + } catch { + return null; + } + } + try { + return await openJson(env, raw); + } catch { + return null; + } +} + +export async function accessDelete(ctx: Ctx): Promise { + const w = await writeAuth(ctx); + if ('deny' in w) return w.deny; + const name = trimTo(String(w.body.name || '').trim(), 60); + if (!name) return fail(400, '缺少凭据名'); + + // 被域名配置引用着的凭据不允许直接删 —— 删了之后续期会在 90 天后才炸 + const cfg = await loadConfig(ctx.env); + const used = cfg.domains.filter((d) => d.dns === name).map((d) => d.name); + if (used.length) return fail(409, `凭据「${name}」正被 ${used.join('、')} 使用,先改掉那些域名的 DNS 设置`); + + await delAccess(ctx.env, name); + await log(ctx, w.ident, 'access', `删除凭据「${name}」`, 'warn'); + return ok({ name }); +} + +// ==================================================================== 证书 + +export async function certList(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + + const cfg = await loadConfig(ctx.env); + const names = await listCertNames(ctx.env); + const items = ( + await Promise.all( + names.map(async (n) => { + const rec = await getCert(ctx.env, n); + if (!rec) { + return { domain: n, hasCert: false, expireAt: null, daysLeft: null, issuer: '', updatedAt: null }; + } + const left = daysLeft(rec.expireAt); + return { + domain: n, + hasCert: true, + expireAt: rec.expireAt, + daysLeft: left, + issuer: rec.issuer || '', + updatedAt: rec.updatedAt, + configured: cfg.domains.some((d) => d.name === n), + level: levelOf(left, cfg.notify.daysBefore, false), + }; + }), + ) + ).sort((x, y) => (x.daysLeft ?? 9999) - (y.daysLeft ?? 9999)); + + return ok({ items, warnDays: cfg.notify.daysBefore }); +} + +/** + * 实测某个域名的**线上**证书。 + * ★ 这是本模块唯一会对外发起网络连接的地方,也是价值最高的一个: + * 只有它才能回答「用户打不开是因为证书过期了」。 + */ +export async function certProbe(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + + let host = String(ctx.url.searchParams.get('host') || '').trim(); + const domain = String(ctx.url.searchParams.get('domain') || '').trim(); + if (!host && domain) { + const cfg = await loadConfig(ctx.env); + const d = cfg.domains.find((x) => x.name === domain); + // 泛域名没法直接握手(`*.usj.cc` 不是合法主机名),挑 SAN 里第一个不带通配的 + host = (d?.san || []).find((s) => !s.startsWith('*.')) || d?.name || ''; + } + if (!host) return fail(400, '缺少 host 参数(或指定的域名没有可探测的 SAN)'); + + const info = await probeTls(host); + if (!info.ok) { + await log(ctx, a.ident, 'probe', `探测 ${host} 失败:${info.error || '未知原因'}`, 'warn', domain || host); + } + return ok({ + host, + ...info, + daysLeft: daysLeft(info.notAfter), + notAfterText: info.notAfter ? formatDateCN(info.notAfter) : null, + }); +} + +/** + * 手工登记一张证书(粘贴 PEM)。 + * 用途:ACME 自动化还没接上时,先把线上证书录进来,让到期监控先跑起来。 + */ +export async function certImport(ctx: Ctx): Promise { + const w = await writeAuth(ctx); + if ('deny' in w) return w.deny; + const { body, ident } = w; + + const domain = trimTo(String(body.domain || '').trim().toLowerCase(), 120); + if (!domain) return fail(400, '缺少 domain'); + const cert = String(body.cert || ''); + const key = String(body.key || ''); + if (!cert.includes('-----BEGIN CERTIFICATE-----')) return fail(400, 'cert 要填 PEM 格式的证书链'); + if (key && !key.includes('-----BEGIN')) return fail(400, 'key 看起来不是 PEM 私钥'); + + const parsed = parsePemInfo(cert); + const expireAt = Number(body.expireAt) || parsed.notAfter || 0; + if (!expireAt) return fail(400, '读不出到期时间,请手工填 expireAt(毫秒时间戳或 ISO 时间)'); + + await putCert(ctx.env, domain, { + cert, + key, + expireAt, + updatedAt: now(), + issuer: trimTo(String(body.issuer || ''), 120), + san: parsed.altNames || [], + }); + await log(ctx, ident, 'import', `登记证书 ${domain}(到期 ${formatDateCN(expireAt)})`, 'info', domain); + return ok({ domain, expireAt }); +} + +export async function certDelete(ctx: Ctx): Promise { + const w = await writeAuth(ctx); + if ('deny' in w) return w.deny; + const domain = trimTo(String(w.body.domain || '').trim().toLowerCase(), 120); + if (!domain) return fail(400, '缺少 domain'); + await delCert(ctx.env, domain); + await log(ctx, w.ident, 'cert', `删除证书记录「${domain}」`, 'warn', domain); + return ok({ domain }); +} + +// ==================================================================== 检查 / 通知 + +/** + * 手动跑一轮检查(只读,不改任何东西)。 + * 对比「库里记录的到期日」与「线上实测」,把不一致的地方标出来 —— + * 这正是 certimate 那几条「过期预警」工作流在做的事,且做得更细。 + */ +export async function certCheck(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + + const cfg = await loadConfig(ctx.env); + const only = String(ctx.url.searchParams.get('domain') || '').trim(); + + const targets = cfg.domains.filter((d) => !d.disabled && (!only || d.name === only)); + if (!targets.length) return ok({ items: [], message: only ? `配置里没有域名「${only}」` : '没有启用的域名' }); + + const items = []; + for (const d of targets) { + const rec = await getCert(ctx.env, d.name); + const storedLeft = daysLeft(rec?.expireAt); + const host = d.san.find((s) => !s.startsWith('*.')) || d.name; + const live = await probeTls(host); + + let verdict = 'unknown'; + if (!live.ok) verdict = 'unreachable'; + else if (live.notAfter && rec?.expireAt) { + // 差 1 天以内算同一张(时间戳精度/时区差异),否则说明线上换了证书 + verdict = Math.abs(live.notAfter - rec.expireAt) < 86400000 ? 'match' : 'mismatch'; + } else if (live.notAfter) verdict = 'live-only'; + + items.push({ + name: d.name, + host, + stored: rec ? { expireAt: rec.expireAt, daysLeft: storedLeft, issuer: rec.issuer || '' } : null, + live: live.ok + ? { + notAfter: live.notAfter || null, + daysLeft: daysLeft(live.notAfter), + issuer: live.issuer || '', + subject: live.subject || '', + altNames: live.altNames || [], + } + : null, + error: live.error || null, + verdict, + }); + } + + const bad = items.filter((i) => i.verdict !== 'match'); + await log( + ctx, + a.ident, + 'check', + `检查 ${items.length} 个域名,${items.length - bad.length} 个一致${bad.length ? ',' + bad.length + ' 个需关注' : ''}`, + bad.length ? 'warn' : 'info', + ); + return ok({ items, warnDays: cfg.notify.daysBefore, checkedAt: now() }); +} + +/** 发一封「到期汇总」邮件(手动触发,用于验证通知链路) */ +export async function certNotify(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + + const cfg = await loadConfig(ctx.env); + if (!cfg.notify.emails.length) return fail(400, '还没配置通知邮箱'); + if (!mailEnabled(ctx.env)) return fail(503, '邮件未配置(缺少 RESEND_API_KEY)'); + + const names = await listCertNames(ctx.env); + const rows: { domain: string; days: number | null }[] = []; + for (const n of names) { + const rec = await getCert(ctx.env, n); + rows.push({ domain: n, days: daysLeft(rec?.expireAt) }); + } + rows.sort((x, y) => (x.days ?? 9999) - (y.days ?? 9999)); + + const html = certMailHtml(rows, cfg.notify.daysBefore); + let sent = 0; + for (const to of cfg.notify.emails) { + if (await sendMail(ctx.env, { to, subject: '证书到期汇总 · 证书管家', html })) sent += 1; + } + await log(ctx, a.ident, 'notify', `发送到期汇总给 ${sent}/${cfg.notify.emails.length} 个收件人`, sent ? 'info' : 'error'); + return ok({ sent, total: cfg.notify.emails.length }); +} + +export function certMailHtml(rows: { domain: string; days: number | null }[], warnDays: number): string { + const line = (r: { domain: string; days: number | null }) => { + const d = r.days; + const color = d == null ? '#888' : d < 0 ? '#d33' : d <= warnDays ? '#e80' : '#2a2'; + const text = d == null ? '无证书记录' : d < 0 ? `已过期 ${-d} 天` : `剩余 ${d} 天`; + return `${esc(r.domain)} + ${text}`; + }; + return `
+

证书到期汇总

+ + + + ${rows.map(line).join('')} +
域名状态
+

由 api.200181.xyz 的证书管家发出 · 提前提醒阈值 ${warnDays} 天

+
`; +} + +function esc(s: unknown): string { + return String(s ?? '').replace(/[&<>"']/g, (m) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m] as string), + ); +} + +// ==================================================================== 日志 + +export async function logList(ctx: Ctx): Promise { + const a = await auth(ctx); + if ('deny' in a) return a.deny; + const limit = Math.min(Math.max(Number(ctx.url.searchParams.get('limit')) || 50, 1), 200); + return ok({ items: (await loadLog(ctx.env)).slice(0, limit) }); +} + +export async function logClear(ctx: Ctx): Promise { + const w = await writeAuth(ctx); + if ('deny' in w) return w.deny; + await clearLog(ctx.env); + await log(ctx, w.ident, 'log', '清空日志'); + return ok({ cleared: true }); +} + +// ==================================================================== 会话 + +/** + * 「我是谁 + 我能不能用证书管家」。 + * 后台前端在决定要不要画「证书管家」这一项时调它 —— 与 /admin/session + * 那条探测路径区分开:那条是给**国内机 editor-api** 用的,形状不能动。 + */ +export async function whoami(ctx: Ctx): Promise { + const ident = await requireSslSession(ctx); + if (!ident) return json({ ok: false, canManage: false, need_login: true }, { status: 401 }); + return ok({ ok: true, canManage: true, user: { id: ident.id, name: ident.name, role: ident.role } }); +} + +/** 给「用户管理」页的角色下拉用的角色说明(前端只读,不做逻辑) */ +export function roleHints(): { value: string; label: string; hint: string }[] { + return [ + { value: 'user', label: '普通用户', hint: '只能评论,进不了后台' }, + { value: 'editor', label: '编辑', hint: '只能写 / 发布自己的文章' }, + { value: 'ssl', label: 'SSL 管理员', hint: '只能配 SSL 证书,碰不到评论和文章' }, + { value: 'admin', label: '管理员', hint: '全部权限' }, + ]; +} + +/** 供 index.ts 注册用(避免路由文件里散落一堆字符串) */ +export const SSL_ROUTES: { method: string; path: string; handler: (ctx: Ctx) => Promise }[] = [ + { method: 'GET', path: '/ssl/whoami', handler: whoami }, + { method: 'GET', path: '/ssl/overview', handler: overview }, + { method: 'GET', path: '/ssl/config', handler: configGet }, + { method: 'POST', path: '/ssl/config', handler: configSave }, + { method: 'GET', path: '/ssl/access', handler: accessList }, + { method: 'POST', path: '/ssl/access', handler: accessSave }, + { method: 'POST', path: '/ssl/access/delete', handler: accessDelete }, + { method: 'GET', path: '/ssl/certs', handler: certList }, + { method: 'GET', path: '/ssl/probe', handler: certProbe }, + { method: 'POST', path: '/ssl/probe', handler: certProbe }, + { method: 'POST', path: '/ssl/cert/import', handler: certImport }, + { method: 'POST', path: '/ssl/cert/delete', handler: certDelete }, + { method: 'GET', path: '/ssl/check', handler: certCheck }, + { method: 'POST', path: '/ssl/check', handler: certCheck }, + { method: 'POST', path: '/ssl/notify', handler: certNotify }, + { method: 'GET', path: '/ssl/log', handler: logList }, + { method: 'POST', path: '/ssl/log/clear', handler: logClear }, +]; + +export type { UserRow }; diff --git a/blog-admin/src/types.ts b/blog-admin/src/types.ts index fa99d164..186c10f3 100644 --- a/blog-admin/src/types.ts +++ b/blog-admin/src/types.ts @@ -85,9 +85,10 @@ export interface PageRow { * 后台角色。 * 'user' —— 普通评论用户(默认,没有后台权限) * 'editor' —— 编辑:只能写/发布自己的文章,后台只看得到「文章编辑」 + * 'ssl' —— SSL 管理员:只能进证书管家,碰不到评论/文章/用户 * 'admin' —— 管理员:全部权限 */ -export type UserRole = 'admin' | 'editor' | 'user'; +export type UserRole = 'admin' | 'editor' | 'ssl' | 'user'; export interface UserRow { id: number; @@ -101,7 +102,7 @@ export interface UserRow { last_ip: string; last_ua: string; is_admin: number; - /** '' | 'editor' | 'admin'(历史行可能为 '',读取请走 roleOf()) */ + /** '' | 'editor' | 'ssl' | 'admin'(历史行可能为 '',读取请走 roleOf()) */ role: string; receive_email: number; is_in_conf: number; diff --git a/blog-admin/tools/probe-ssl-ui.mjs b/blog-admin/tools/probe-ssl-ui.mjs new file mode 100644 index 00000000..bab08f1a --- /dev/null +++ b/blog-admin/tools/probe-ssl-ui.mjs @@ -0,0 +1,316 @@ +/** + * 证书管家后台页面的**真实浏览器验证**。 + * + * 为什么不能只靠 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 项。 + * + * 踩坑记录: + * · 剥 \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); diff --git a/blog-admin/tools/selftest-ssl.mjs b/blog-admin/tools/selftest-ssl.mjs new file mode 100644 index 00000000..39a9d321 --- /dev/null +++ b/blog-admin/tools/selftest-ssl.mjs @@ -0,0 +1,475 @@ +/** + * 证书管家本地自测 —— 不启动 wrangler,直接把编译后的 routes/ssl.ts 跑一遍。 + * + * 为什么不用 wrangler dev:本项目的历史教训是「本地 workerd 的行为和线上不一致」 + * (例如 PBKDF2 迭代数、Workers Cache),所以凡是**纯逻辑**的部分(鉴权矩阵、 + * 配置校验、加密往返、脱敏)都在这里用假 KV/假 D1 跑,跑得快也跑得准; + * 真正需要边界环境的(TLS 探测、Workers Cache)放到线上验证。 + * + * 用法(两步,先编译再跑): + * npm run selftest:ssl + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..'); +const outDir = path.join(root, '.selftest-ssl'); + +const toFileUrl = (p) => pathToFileURL(p).href; + +// 编译 src → CJS(只编需要的几个文件;types.ts 是纯类型,会被擦掉) +// ★ 必须带 --strict:KVNamespaceListResult 是「完成/未完成」的联合类型, +// 只有 strict 下的控制流分析才能按 list_complete 把它收窄(`strict:false` +// 时 TS 会把两支合并,读 page.cursor 直接报 TS2339)。 +// 这也说明「编译过了」和「按项目配置编译过了」是两件事。 +// +// ★ 编译交给**外层 shell** 做(package.json 里的 selftest:ssl),不在这里 +// spawn —— 本机的沙箱会让 spawnSync 直接 EBUSY(status=null、stdout 全是 +// undefined,报错信息毫无指向性)。这里只做一件事:产物在不在。 +if (!fs.existsSync(path.join(outDir, 'routes', 'ssl.js'))) { + console.error('找不到编译产物 ' + path.join(outDir, 'routes/ssl.js')); + console.error('请先跑:npx tsc src/routes/ssl.ts src/lib/{role,certstore,certvault,certprobe}.ts \\'); + console.error(' --outDir .selftest-ssl --module commonjs --target es2022 \\'); + console.error(' --moduleResolution node --strict \\'); + console.error(' --types ./node_modules/@cloudflare/workers-types \\'); + console.error(' --skipLibCheck --esModuleInterop --resolveJsonModule'); + process.exit(1); +} + +/** ★ toFileUrl 定义在文件顶部(pathToFileURL)—— Windows 路径必须走它, + * 手写 `'file://' + p` 在盘符前少一个斜杠,import 会报 ERR_UNSUPPORTED_ESM_URL_SCHEME。 */ + +// ---------------------------------------------------------------- 测试脚手架 + +let pass = 0; +let fail = 0; +const failures = []; + +function t(name, ok, extra) { + if (ok) { pass++; console.log(' ✓ ' + name); } + else { fail++; failures.push(name); console.log(' ✗ ' + name + (extra ? ' → ' + extra : '')); } +} + +function eq(name, got, want) { + t(name + `(期望 ${JSON.stringify(want)},实得 ${JSON.stringify(got)})`, JSON.stringify(got) === JSON.stringify(want)); +} + +/** 内存版 KVNamespace,够用(get / put / delete / list) */ +function memKV() { + const m = new Map(); + return { + _m: m, + async get(k) { return m.has(k) ? m.get(k) : null; }, + async put(k, v) { m.set(k, String(v)); }, + async delete(k) { m.delete(k); }, + async list({ prefix = '', cursor } = {}) { + const keys = [...m.keys()].filter((k) => k.startsWith(prefix)).sort(); + return { keys: keys.map((name) => ({ name })), list_complete: true, cursor: undefined, cacheStatus: null }; + }, + }; +} + +const KV = memKV(); +const ENV = { + RSS_KV: KV, + TOKEN_SECRET: 'test-secret-please-rotate', + ALLOWED_ORIGINS: 'https://api.200181.xyz', + DB: { + // getAdminUsers 会查 D1;这里给一张空表 + prepare: () => ({ bind: () => ({ all: async () => ({ results: [] }), first: async () => null }) }), + }, +}; + +const ssl = await import(toFileUrl(path.join(outDir, 'routes', 'ssl.js'))); +const store = await import(toFileUrl(path.join(outDir, 'lib', 'certstore.js'))); +const vault = await import(toFileUrl(path.join(outDir, 'lib', 'certvault.js'))); +const role = await import(toFileUrl(path.join(outDir, 'lib', 'role.js'))); + +/** 造一个 Ctx */ +function ctx({ method = 'GET', url = 'https://api.200181.xyz/api/v2/ssl/x', user = null, body, origin } = {}) { + const headers = new Headers(); + if (origin) headers.set('Origin', origin); + if (body !== undefined) headers.set('Content-Type', 'application/json'); + return { + env: ENV, + req: new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }), + url: new URL(url), + params: {}, + user, + }; +} + +const ADMIN = { id: 1, name: '群林', email: 'a@b.c', is_admin: 1, role: 'admin' }; +const SSLU = { id: 2, name: '阿美', email: 's@b.c', is_admin: 0, role: 'ssl' }; +const EDITOR = { id: 3, name: '小明', email: 'e@b.c', is_admin: 0, role: 'editor' }; +const PLAIN = { id: 4, name: '路人', email: 'p@b.c', is_admin: 0, role: '' }; + +const j = async (res) => ({ status: res.status, body: await res.json() }); + +// ================================================================ 1. 角色 +console.log('\n[1] 角色归一化与权限判定'); +eq('roleOf(admin)', role.roleOf(ADMIN), 'admin'); +eq('roleOf(ssl)', role.roleOf(SSLU), 'ssl'); +eq('roleOf(editor)', role.roleOf(EDITOR), 'editor'); +eq('roleOf(普通)', role.roleOf(PLAIN), 'user'); +eq('roleOf(null)', role.roleOf(null), 'user'); +t('is_admin=1 优先于 role 列', role.roleOf({ is_admin: 1, role: '' }) === 'admin'); +t('canManageSSL(admin)=true', role.canManageSSL(ADMIN) === true); +t('canManageSSL(ssl)=true', role.canManageSSL(SSLU) === true); +t('★ canManageSSL(editor)=false', role.canManageSSL(EDITOR) === false); +t('★ canManageSSL(普通)=false', role.canManageSSL(PLAIN) === false); +t('canWritePosts(ssl)=false', role.canWritePosts(SSLU) === false); +eq('normalizeRole("ssl")', role.normalizeRole('ssl', undefined), 'ssl'); +eq('normalizeRole("SSL")(大小写容错)', role.normalizeRole('SSL', undefined), 'ssl'); +eq('normalizeRole("胡说")', role.normalizeRole('胡说', undefined), ''); +eq('normalizeRole(undefined, true)', role.normalizeRole(undefined, true), 'admin'); +eq('roleLabel("ssl")', role.roleLabel('ssl'), 'SSL 管理员'); + +// ================================================================ 2. 保险箱 +console.log('\n[2] AES-GCM 保险箱'); +const secretObj = { type: 'tencentcloud', secretId: 'AKIDabcdefghijklmn', secretKey: 'verysecretkey12345' }; +const sealed = await vault.sealJson(ENV, secretObj); +t('密文带 v1. 前缀', sealed.startsWith('v1.')); +t('密文里看不到明文', !sealed.includes('AKIDabcdefghijklmn') && !sealed.includes('verysecretkey')); +t('isSealed 认得出', vault.isSealed(sealed) === true); +eq('解密往返一致', await vault.openJson(ENV, sealed), secretObj); +{ + const sealed2 = await vault.sealJson(ENV, secretObj); + t('★ 同一明文两次加密结果不同(IV 随机)', sealed !== sealed2); +} +t('isSealed(乱码)=false', vault.isSealed('hello') === false); +{ + const wrongKeyEnv = { ...ENV, TOKEN_SECRET: 'another-secret' }; + let threw = false; + try { await vault.openJson(wrongKeyEnv, sealed); } catch { threw = true; } + t('★ 换密钥后解密抛错(不返回半成品)', threw); + eq('peekJson 解不开返回 null', await vault.peekJson(wrongKeyEnv, sealed), null); +} +eq('maskSecret 长串', vault.maskSecret('AKIDabcdefghijklmn'), 'AKID********klmn'); +eq('maskSecret 短串', vault.maskSecret('abc'), '***'); +eq('maskSecret 空', vault.maskSecret(''), ''); + +// ================================================================ 3. 存储层 +console.log('\n[3] 存储层(配置 / 凭据 / 证书 / 日志)'); +{ + const c0 = await store.loadConfig(ENV); + eq('空 KV → 默认配置 version', c0.version, 1); + eq('空 KV → 空域名列表', c0.domains, []); + eq('空 KV → 默认提醒 30 天', c0.notify.daysBefore, 30); + + await store.saveConfig(ENV, { + version: 1, + notify: { emails: ['a@b.c'], daysBefore: 21 }, + domains: [{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tc', deploy: ['1panel'] }], + }); + const c1 = await store.loadConfig(ENV); + eq('保存后读回域名数', c1.domains.length, 1); + eq('保存后读回提醒天数', c1.notify.daysBefore, 21); + + // 老配置缺字段的迁移 + await KV.put('certkeeper:config', JSON.stringify({ domains: [{ name: 'x.cc' }] })); + const c2 = await store.loadConfig(ENV); + eq('缺 notify 时补默认', c2.notify.daysBefore, 30); + eq('域名缺 san 时补空数组', c2.domains[0].san, []); + eq('域名缺 deploy 时补空数组', c2.domains[0].deploy, []); + + await KV.put('certkeeper:config', '{坏 JSON'); + let threw = false; + try { await store.loadConfig(ENV); } catch { threw = true; } + t('★ 配置坏 JSON 时抛错(不返回空配置)', threw); + + // 复位:上面故意写坏的配置要清掉,否则后面每个接口都会 500 + await KV.delete('certkeeper:config'); + eq('复位后能正常读配置', (await store.loadConfig(ENV)).version, 1); +} + +{ + await store.putAccess(ENV, 'tc-main', { type: 'tencentcloud', note: '主号', secretId: 'AKIDxyz123456789', secretKey: 'kkkkkkkkkkkk' }); + const raw = await KV.get('certkeeper:access:tc-main'); + t('★ KV 里落的是密文(不含明文密钥)', !raw.includes('AKIDxyz123456789') && vault.isSealed(raw)); + + const got = await store.getAccess(ENV, 'tc-main'); + eq('getAccess 解出明文 type', got.type, 'tencentcloud'); + eq('getAccess 解出明文 secretId', got.secretId, 'AKIDxyz123456789'); + + const list = await store.listAccess(ENV); + eq('列表有一条', list.length, 1); + eq('★ 列表回显 type', list[0].type, 'tencentcloud'); + eq('★ 列表回显脱敏 secretId', list[0].fields.secretId, 'AKID********6789'); + t('★ 列表里没有明文密钥', !JSON.stringify(list).includes('AKIDxyz123456789')); + t('★ 列表里没有明文 secretKey', !JSON.stringify(list).includes('kkkkkkkkkkkk')); + + // 解不开的凭据也要列出来(密钥轮换后的场景) + await KV.put('certkeeper:access:broken', 'v1.AAAA.BBBB'); + const list2 = await store.listAccess(ENV); + const broken = list2.find((x) => x.name === 'broken'); + t('★ 解不开的凭据仍出现在列表里', !!broken); + t('★ 解不开的凭据被标 unreadable', broken && broken.unreadable === true); + await KV.delete('certkeeper:access:broken'); +} + +{ + const rec = { cert: 'PEM', key: 'KEY', expireAt: Date.now() + 86400000, updatedAt: Date.now(), issuer: 'LiteSSL' }; + await store.putCert(ENV, 'USJ.CC', rec); // 大小写混写 + eq('★ 证书域名自动小写化后读得到', (await store.getCert(ENV, 'usj.cc')).issuer, 'LiteSSL'); + eq('大写也读得到同一张', (await store.getCert(ENV, 'USJ.CC')).issuer, 'LiteSSL'); + const raw = await KV.get('certkeeper:cert:usj.cc'); + t('★ 证书(含私钥)落 KV 是密文', vault.isSealed(raw) && !raw.includes('KEY')); + eq('listCertNames', await store.listCertNames(ENV), ['usj.cc']); + + await KV.put('certkeeper:cert:bad.cc', 'v1.XXXX.YYYY'); + eq('★ 证书解不开时返回 null 而不是抛', await store.getCert(ENV, 'bad.cc'), null); + await KV.delete('certkeeper:cert:bad.cc'); +} + +{ + for (let i = 0; i < 205; i++) { + await store.appendLog(ENV, { at: i, level: 'info', action: 'check', message: 'm' + i }); + } + const logs = await store.loadLog(ENV); + eq('★ 日志环形缓冲上限 200', logs.length, 200); + eq('日志最新在最前', logs[0].message, 'm204'); + await store.clearLog(ENV); + eq('清空后为空', (await store.loadLog(ENV)).length, 0); +} + +// ================================================================ 4. 鉴权矩阵 +console.log('\n[4] HTTP 鉴权矩阵(这是最关键的一组)'); +{ + const cases = [ + ['匿名 GET /overview', null, 403], + ['普通用户 GET /overview', PLAIN, 403], + ['★ 编辑 GET /overview(不该有证书权限)', EDITOR, 403], + ['SSL 管理员 GET /overview', SSLU, 200], + ['管理员 GET /overview', ADMIN, 200], + ]; + for (const [name, user, want] of cases) { + const r = await ssl.overview(ctx({ user })); + eq(name, r.status, want); + } + + // 写接口 + const wcases = [ + ['匿名 POST /config', null, 403], + ['★ 编辑 POST /config', EDITOR, 403], + ['SSL 管理员 POST /config(合法体)', SSLU, 200], + ]; + for (const [name, user, want] of wcases) { + const r = await ssl.configSave(ctx({ + method: 'POST', user, + body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] }, + })); + eq(name, r.status, want); + } + + // CSRF + { + const r = await ssl.configSave(ctx({ + method: 'POST', user: ADMIN, origin: 'https://evil.example.com', + body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] }, + })); + eq('★ 跨站 Origin 的写请求被拒', r.status, 403); + } + { + const r = await ssl.configSave(ctx({ + method: 'POST', user: ADMIN, origin: 'https://api.200181.xyz', + body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] }, + })); + eq('同源写请求放行', r.status, 200); + } +} + +// ================================================================ 5. 配置校验 +console.log('\n[5] 配置校验(错了要在保存时就报,别等 90 天后)'); +{ + await store.putAccess(ENV, 'tc1', { type: 'tencentcloud', secretId: 'a', secretKey: 'b' }); + + const bad = [ + ['缺 name', { domains: [{ san: ['a.cc'], dns: 'tc1' }] }], + ['不像域名', { domains: [{ name: 'not a domain!', san: ['a.cc'], dns: 'tc1' }] }], + ['缺 SAN', { domains: [{ name: 'a.cc', san: [], dns: 'tc1' }] }], + ['缺 DNS 凭据名', { domains: [{ name: 'a.cc', san: ['a.cc'] }] }], + ['★ 引用了不存在的凭据', { domains: [{ name: 'a.cc', san: ['a.cc'], dns: 'nope' }] }], + ['不认识的部署目标', { domains: [{ name: 'a.cc', san: ['a.cc'], dns: 'tc1', deploy: ['k8s'] }] }], + ['重复域名', { domains: [ + { name: 'a.cc', san: ['a.cc'], dns: 'tc1' }, + { name: 'a.cc', san: ['a.cc'], dns: 'tc1' }, + ] }], + ]; + for (const [name, body] of bad) { + const r = await ssl.configSave(ctx({ + method: 'POST', user: ADMIN, + body: { version: 1, notify: { emails: [], daysBefore: 30 }, ...body }, + })); + t('拒绝:' + name, r.status === 400, 'got ' + r.status); + } + + const okBody = { + version: 1, + notify: { emails: ['me@example.com'], daysBefore: 45 }, + domains: [{ name: 'USJ.CC', san: ['USJ.CC', '*.USJ.CC'], dns: 'tc1', deploy: ['1panel', 'dogecloud'] }], + }; + const r = await ssl.configSave(ctx({ method: 'POST', user: ADMIN, body: okBody })); + eq('合法配置保存成功', r.status, 200); + const saved = await store.loadConfig(ENV); + eq('★ 域名自动小写化', saved.domains[0].name, 'usj.cc'); + eq('★ SAN 自动小写化', saved.domains[0].san, ['usj.cc', '*.usj.cc']); + eq('提醒阈值保存正确', saved.notify.daysBefore, 45); + + // 提醒天数越界 + for (const days of [0, -5, 400]) { + const rr = await ssl.configSave(ctx({ + method: 'POST', user: ADMIN, + body: { version: 1, notify: { emails: [], daysBefore: days }, domains: [] }, + })); + t('拒绝越界提醒天数 ' + days, rr.status === 400, 'got ' + rr.status); + } + // 邮箱格式 + { + const rr = await ssl.configSave(ctx({ + method: 'POST', user: ADMIN, + body: { version: 1, notify: { emails: ['not-an-email'], daysBefore: 30 }, domains: [] }, + })); + eq('拒绝非法邮箱', rr.status, 400); + } +} + +// ================================================================ 6. 凭据接口 +console.log('\n[6] 凭据接口'); +{ + const r = await ssl.accessSave(ctx({ + method: 'POST', user: ADMIN, + body: { name: 'new-acc', type: 'cloudflare', note: 'CF', fields: { apiToken: 'cf-token-abcdefgh' } }, + })); + eq('新建凭据', r.status, 200); + eq('凭据类型落库', (await store.getAccess(ENV, 'new-acc')).type, 'cloudflare'); + + // 只改备注:不该把脱敏值(含 ****)写进去 + const r2 = await ssl.accessSave(ctx({ + method: 'POST', user: ADMIN, + body: { name: 'new-acc', type: 'cloudflare', note: '改过备注', fields: { apiToken: 'cf-t********gh' } }, + })); + eq('只改备注返回 200', r2.status, 200); + const after = await store.getAccess(ENV, 'new-acc'); + eq('★ 脱敏值没被当成新密钥写进去', after.apiToken, 'cf-token-abcdefgh'); + eq('备注已更新', after.note, '改过备注'); + + // 非法类型 / 名字 + const r3 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: 'x', type: '随便', fields: { a: 'b' } } })); + eq('拒绝非法类型', r3.status, 400); + const r4 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: '有空格 和/斜杠', type: 'cloudflare', fields: { a: 'b' } } })); + eq('拒绝非法凭据名', r4.status, 400); + const r5 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: 'never-existed', type: 'cloudflare', fields: {} } })); + eq('拒绝「新建但没填密钥」', r5.status, 400); + + // 被引用的凭据不能删 + const r6 = await ssl.accessDelete(ctx({ method: 'POST', user: ADMIN, body: { name: 'tc1' } })); + eq('★ 被域名引用的凭据删不掉', r6.status, 409); + const r7 = await ssl.accessDelete(ctx({ method: 'POST', user: ADMIN, body: { name: 'new-acc' } })); + eq('未被引用的凭据可删', r7.status, 200); + eq('删除后读不到', await store.getAccess(ENV, 'new-acc'), null); +} + +// ================================================================ 7. 证书登记 +console.log('\n[7] 证书登记 / 删除'); +{ + const pem = '-----BEGIN CERTIFICATE-----\nTUlJQmRENDAdGVzdA==\n-----END CERTIFICATE-----'; + const r = await ssl.certImport(ctx({ + method: 'POST', user: ADMIN, + body: { domain: 'usj.cc', cert: pem, key: '', expireAt: Date.UTC(2027, 0, 5), issuer: 'LiteSSL' }, + })); + eq('登记证书', r.status, 200); + const rec = await store.getCert(ENV, 'usj.cc'); + eq('到期时间落库', rec.expireAt, Date.UTC(2027, 0, 5)); + eq('签发方落库', rec.issuer, 'LiteSSL'); + + const r2 = await ssl.certImport(ctx({ + method: 'POST', user: ADMIN, body: { domain: 'x.cc', cert: '不是 PEM', expireAt: 1 }, + })); + eq('拒绝非 PEM 内容', r2.status, 400); + const r3 = await ssl.certImport(ctx({ + method: 'POST', user: ADMIN, body: { domain: 'y.cc', cert: pem, expireAt: 0 }, + })); + eq('★ 读不出到期时间且没填 → 拒绝', r3.status, 400); + + // certList + const rl = await j(await ssl.certList(ctx({ user: ADMIN }))); + eq('certList 状态码', rl.status, 200); + const item = rl.body.items.find((x) => x.domain === 'usj.cc'); + t('certList 带上 configured 标记', item && item.configured === true); + t('certList 算出剩余天数', item && typeof item.daysLeft === 'number' && item.daysLeft > 0); + t('certList 按剩余天数升序', rl.body.items.every((x, i, a) => i === 0 || (a[i - 1].daysLeft ?? 9999) <= (x.daysLeft ?? 9999))); + + const rd = await ssl.certDelete(ctx({ method: 'POST', user: ADMIN, body: { domain: 'usj.cc' } })); + eq('删除证书', rd.status, 200); + eq('删除后读不到', await store.getCert(ENV, 'usj.cc'), null); +} + +// ================================================================ 8. 日志接口 +console.log('\n[8] 日志 / whoami'); +{ + await store.appendLog(ENV, { at: Date.now(), level: 'warn', action: 'check', message: '测试日志' }); + const r = await j(await ssl.logList(ctx({ user: SSLU }))); + eq('SSL 管理员能读日志', r.status, 200); + t('日志有内容', r.body.items.length >= 1); + + const rw = await j(await ssl.whoami(ctx({ user: SSLU }))); + eq('whoami 对 SSL 管理员返回 ok', rw.status, 200); + eq('whoami 带出角色', rw.body.user.role, 'ssl'); + + const rw2 = await j(await ssl.whoami(ctx({ user: EDITOR }))); + eq('★ whoami 对编辑返回 401', rw2.status, 401); + eq('whoami 明确 canManage=false', rw2.body.canManage, false); +} + +// ================================================================ 9. 概览分级 +console.log('\n[9] 概览的到期分级'); +{ + await store.saveConfig(ENV, { + version: 1, + notify: { emails: [], daysBefore: 30 }, + domains: [ + { name: 'ok.cc', san: ['ok.cc'], dns: 'tc1', deploy: [] }, + { name: 'soon.cc', san: ['soon.cc'], dns: 'tc1', deploy: [] }, + { name: 'dead.cc', san: ['dead.cc'], dns: 'tc1', deploy: [] }, + { name: 'off.cc', san: ['off.cc'], dns: 'tc1', deploy: [], disabled: true }, + { name: 'none.cc', san: ['none.cc'], dns: 'tc1', deploy: [] }, + ], + }); + const day = 86400000; + await store.putCert(ENV, 'ok.cc', { cert: '', key: '', expireAt: Date.now() + 80 * day, updatedAt: Date.now() }); + await store.putCert(ENV, 'soon.cc', { cert: '', key: '', expireAt: Date.now() + 10 * day, updatedAt: Date.now() }); + await store.putCert(ENV, 'dead.cc', { cert: '', key: '', expireAt: Date.now() - 3 * day, updatedAt: Date.now() }); + await store.putCert(ENV, 'off.cc', { cert: '', key: '', expireAt: Date.now() + 5 * day, updatedAt: Date.now() }); + + const r = await j(await ssl.overview(ctx({ user: ADMIN }))); + const by = Object.fromEntries(r.body.domains.map((d) => [d.name, d])); + eq('80 天 → ok', by['ok.cc'].level, 'ok'); + eq('10 天 → warn', by['soon.cc'].level, 'warn'); + eq('已过期 → danger', by['dead.cc'].level, 'danger'); + eq('★ 已停用的域名不报临期', by['off.cc'].level, 'none'); + eq('★ 已过期剩余天数为负', by['dead.cc'].daysLeft, -3); + eq('没证的 → none', by['none.cc'].level, 'none'); + eq('没证的 hasCert=false', by['none.cc'].hasCert, false); +} + +// ================================================================ 10. 邮件 HTML +console.log('\n[10] 提醒邮件 HTML 转义'); +{ + const html = ssl.certMailHtml([{ domain: '.cc', days: -2 }], 30); + t('★ 邮件里域名被 HTML 转义', !html.includes('