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', '证书管家') +
+ '' +
'需要 Cloudflare
' +
'');
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 '' +
+ '| 域名 / SAN | 剩余天 | 签发方 / 到期 | DNS 凭据 | 部署目标 | | ' +
+ '
' + rows + '
' +
+ (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 + '」',
+ '' +
+ '' +
+ '' +
+ '' +
+ '多吉云 CDN 要绑的域名(逗号分隔,留空 = 不部署到多吉云)' +
+ '' +
+ '1Panel 站点名(逗号分隔,留空 = 不部署到 1Panel)' +
+ '' +
+ '' +
+ '' +
+ '停用(保留配置,不参与检查与续期)' +
+ '' +
+ (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 '' + esc(label) + '' +
+ '';
+ }).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]) =>
+ '' + esc(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 : ''),
+ '域名' +
+ '' +
+ '证书链 PEM(fullchain)' +
+ '' +
+ '私钥 PEM(可留空,留空则只做监控不参与部署)' +
+ '' +
+ '签发方(可选,如 LiteSSL / Let\'s Encrypt)' +
+ '' +
+ '');
+
+ $('#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('