feat: SSL 证书管家 —— 后台面板 + 专属角色 + 国内机放行

集成在 api.200181.xyz(同一个 Worker),国内机 writeapi.usj.cc 同步可用。

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

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

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

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

验证:typecheck ✓ / selftest:ssl 117 项 ✓ / 浏览器 37 项 ✓
This commit is contained in:
zqlit committed 2026-10-06 14:19:01 +08:00
1 parent ca2fb7163d
commit a40a92f526
16 files changed
+2791 -59

No files matched your search

+3
View File
@@ -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)}
/* ---------------------------------------------------------- 评论列表 */
+519 -26
View File
@@ -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 ' <span class="tag acc">管理员</span>';
if (r === 'editor') return ' <span class="tag">编辑</span>';
if (r === 'ssl') return ' <span class="tag ssl">SSL</span>';
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('<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 1 0-6.4-6.4l-1.6 1.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 1 0 6.4 6.4l1.6-1.6"/>'),
settings: I('<circle cx="12" cy="12" r="3.2"/><path d="M12 2.8v2.4M12 18.8v2.4M4.9 4.9l1.7 1.7M17.4 17.4l1.7 1.7M2.8 12h2.4M18.8 12h2.4M4.9 19.1l1.7-1.7M17.4 6.6l1.7-1.7"/>'),
status: I('<path d="M3 12h4l3-8 4 16 3-8h4"/>'),
lock: I('<rect x="4.5" y="10.5" width="15" height="10" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/><path d="M12 14.5v2.5"/>'),
moon: I('<path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11z"/>'),
out: I('<path d="M9 4H5.5A1.5 1.5 0 0 0 4 5.5v13A1.5 1.5 0 0 0 5.5 20H9"/><path d="M15 16l4-4-4-4M19 12H9"/>'),
eye: I('<path d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>'),
@@ -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() ? ' <span class="tag" style="font-size:10px">编辑</span>' : '');
$('#footR').textContent = 'API v2 · 站点:' + (state.site || '—');
// 侧栏显示身份:编辑 / SSL 管理员标一下,免得 TA 以为自己权限跟管理员一样
const tag = isEditor()
? ' <span class="tag" style="font-size:10px">编辑</span>'
: isSslOnly()
? ' <span class="tag ssl" style="font-size:10px">SSL</span>'
: '';
$('#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() {
'<button class="tab' + (state.tab === id ? ' on' : '') + '" data-act="tab" data-id="' + id + '">' +
(ICONS[id] || '') + '<span>' + label + '</span></button>';
// 编辑角色:订阅源/友链在国内机侧已经被拒(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() {
'<div class="nav-group">订阅中心</div>' +
t('feeds', '订阅源') + t('links', '友链') +
'<div class="nav-sep"></div>' +
'<div class="nav-group">证书</div>' +
t('ssl', '证书管家') +
'<div class="nav-sep"></div>' +
'<div class="nav-group">需要 Cloudflare</div>' +
'<button class="tab" data-act="relogin">' + I('<path d="M12 3v9"/><path d="M7 7.5a7 7 0 1 0 10 0"/>') + '<span>登录完整后台</span></button>');
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 += '<div class="nav-sep"></div>' +
'<div class="nav-group">线路</div>' +
'<button class="tab" data-act="cn-line">' + I('<path d="M3 12h4l3 -7 4 14 3 -7h4"/>') +
'<span>国内线路(直连)</span></button>';
// ★ SSL 管理员不给这个入口:国内机后台目前只认 admin/editor 两种角色
// (见 editor-api 的 handoff 校验),跳过去会签不出会话。
if (!isSslOnly()) {
html += '<div class="nav-sep"></div>' +
'<div class="nav-group">线路</div>' +
'<button class="tab" data-act="cn-line">' + I('<path d="M3 12h4l3 -7 4 14 3 -7h4"/>') +
'<span>国内线路(直连)</span></button>';
}
$('#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) {
'<select id="' + id + '"' + (self ? ' disabled' : '') + '>' +
opt('user', '普通用户(只能评论,进不了后台)') +
opt('editor', '编辑(只能写 / 发布自己的文章)') +
opt('ssl', 'SSL 管理员(只能配证书,碰不到评论和文章)') +
opt('admin', '管理员(全部权限)') +
'</select>' +
(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 = '<div class="loading">正在读证书状态…</div>';
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 + ' 天',
'<button class="btn tiny" data-act="ssl-check"><span>立即检查</span></button>' +
'<button class="btn tiny" data-act="ssl-notify"><span>发提醒邮件</span></button>' +
'<button class="btn tiny" data-act="ssl-add-domain"><span>+ 域名</span></button>' +
(ov.mailEnabled ? '' : '<span class="tag warn">邮件未配置</span>')) +
sslWarnBanner(ov) +
'<div id="ssl-list">' + sslDomainTable(ov) + '</div>' +
'<div class="grid2" style="margin-top:14px">' +
'<section class="card"><header class="card-head"><h3>凭据(密钥只显示尾 4 位)</h3>' +
'<button class="btn tiny" style="margin-left:auto" data-act="ssl-access-new"><span>+ 凭据</span></button>' +
'</header><div class="card-body" id="ssl-access"><div class="loading">加载中…</div></div></section>' +
'<section class="card"><header class="card-head"><h3>最近执行记录</h3>' +
'<button class="btn tiny" style="margin-left:auto" data-act="ssl-log-clear"><span>清空</span></button>' +
'</header><div class="card-body" id="ssl-log"><div class="loading">加载中…</div></div></section>' +
'</div>' +
'<section class="card" style="margin-top:14px"><header class="card-head"><h3>通知设置</h3></header>' +
'<div class="card-body"><div id="ssl-notify-cfg">' + sslNotifyForm(ov) + '</div></div></section>';
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('<b>' + expiring.length + '</b> 个域名即将或已经过期:' +
expiring.map((d) => esc(d.name) + '(' + (d.daysLeft == null ? '无记录' : d.daysLeft + ' 天') + ')').join('、'));
}
if (noCert.length) {
parts.push('<b>' + noCert.length + '</b> 个域名还没有证书记录(只影响监控,不影响线上):' +
noCert.map((d) => esc(d.name)).join('、'));
}
return '<div class="alert" style="background:var(--danger-soft);border-color:var(--danger-line);color:var(--danger)">' +
parts.join('<br>') + '</div>';
}
function sslLevelTag(d) {
if (d.disabled) return '<span class="tag line">已停用</span>';
if (!d.hasCert) return '<span class="tag warn">无记录</span>';
if (d.level === 'danger') return '<span class="tag dang">' + (d.daysLeft < 0 ? '已过期' : '紧急') + '</span>';
if (d.level === 'warn') return '<span class="tag warn">临期</span>';
return '<span class="tag ok">正常</span>';
}
function sslDomainTable(ov) {
if (!ov.domains.length) {
return '<div class="empty"><b>还没有配置任何域名</b>' +
'点右上角「+ 域名」加第一个。域名不会立刻签发证书,只是纳入监控与后续自动化。</div>';
}
const rows = ov.domains.map((d) =>
'<tr>' +
'<td><b>' + esc(d.name) + '</b>' + sslLevelTag(d) +
'<div class="lk">' + esc(d.san.join(' · ')) + '</div></td>' +
'<td class="num">' + (d.daysLeft == null ? '—' : (d.daysLeft < 0 ? '<span style="color:var(--danger)">' + d.daysLeft + '</span>' : d.daysLeft)) + '</td>' +
'<td>' + esc(d.issuer || '—') + '<div class="lk">' + esc(d.expireText || '—') + '</div></td>' +
'<td>' + esc(d.dns || '—') + '</td>' +
'<td>' + (d.deploy.length ? d.deploy.map((t) => '<span class="tag line">' + esc(t) + '</span>').join(' ') : '<span style="color:var(--muted)">—</span>') + '</td>' +
'<td style="white-space:nowrap">' +
'<button class="btn tiny" data-act="ssl-probe" data-host="' + esc(d.san.find((s) => !s.startsWith('*.')) || d.name) + '">实测</button> ' +
'<button class="btn tiny" data-act="ssl-import" data-domain="' + esc(d.name) + '">登记</button> ' +
'<button class="btn tiny" data-act="ssl-edit-domain" data-domain="' + esc(d.name) + '">编辑</button>' +
'</td>' +
'</tr>'
).join('');
return '<div class="tblwrap"><table><thead><tr>' +
'<th>域名 / SAN</th><th class="num">剩余天</th><th>签发方 / 到期</th><th>DNS 凭据</th><th>部署目标</th><th></th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>' +
(ov.orphans.length
? '<div class="alert" style="margin-top:12px">库里有 ' + ov.orphans.length +
' 条孤儿证书记录(域名已从配置里删掉):' + esc(ov.orphans.join('、')) +
'。不影响使用,要清理点上面的「登记」旁的删除,或忽略即可。</div>'
: '');
}
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 = '<div class="empty" style="padding:24px 12px"><b>还没有凭据</b>' +
'DNS-01 需要一把能改 DNS 的钥匙(腾讯云 CAM / Cloudflare Token),部署需要多吉云和 1Panel 的。' +
'密钥用 TOKEN_SECRET 派生密钥 AES-GCM 加密后落 KV,列表只回显尾 4 位。</div>';
return;
}
box.innerHTML = '<div class="mini">' + items.map((a) =>
'<div class="mini-i">' +
'<span class="b"><span class="nm">' + esc(a.name) + '</span>' +
' <span class="tag line">' + esc(a.type || '未知') + '</span>' +
(a.unreadable ? ' <span class="tag dang">读不出</span>' : '') +
'<div class="lk" style="font-size:11px;color:var(--muted);margin-top:2px">' +
esc(Object.entries(a.fields).map(([k, v]) => k + '=' + v).join(' ') || (a.note || '(未填密钥)')) +
'</div></span>' +
'<button class="btn tiny" data-act="ssl-access-edit" data-name="' + esc(a.name) + '">编辑</button> ' +
'<button class="btn tiny" data-act="ssl-access-del" data-name="' + esc(a.name) + '">删除</button>' +
'</div>').join('') + '</div>';
}
/** 打开弹窗前确保凭据列表已加载(正常路径下 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
? '<div class="mini">' + items.map((l) => {
const color = l.level === 'error' ? 'var(--danger)' : l.level === 'warn' ? 'var(--warning)' : 'var(--muted)';
return '<div class="mini-i"><span class="n" style="width:76px">' +
esc(new Date(l.at + 8 * 3600e3).toISOString().slice(5, 16).replace('T', ' ')) + '</span>' +
'<span class="b"><span class="tx" style="color:' + color + '">[' + esc(l.action) + ']</span> ' +
esc(l.message) + '</span></div>';
}).join('') + '</div>'
: '<div class="empty" style="padding:24px 12px">还没有执行记录</div>';
}
function sslNotifyForm(ov) {
return '<div style="display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap">' +
'<label class="field" style="flex:1;min-width:260px;margin:0"><span class="field-k">提醒收件人(逗号或换行分隔)</span>' +
'<input id="sslNotifyEmails" value="' + esc(ov.notify.emails.join(', ')) + '" placeholder="you@example.com"></label>' +
'<label class="field" style="width:150px;margin:0"><span class="field-k">提前天数</span>' +
'<input id="sslNotifyDays" type="number" min="1" max="365" value="' + ov.notify.daysBefore + '"></label>' +
'<button class="btn primary" data-act="ssl-notify-save"><span>保存</span></button>' +
'</div>';
}
/** 域名的编辑弹窗。新建时 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('先建凭据', '<p style="margin-top:0">配域名之前得先有 DNS 凭据 —— DNS-01 验证要往你的域名下写 TXT 记录。' +
'先去「凭据」那一栏加一把腾讯云 CAM 或 Cloudflare Token。</p>' +
'<div style="text-align:right"><button class="btn" data-act="modal-close"><span>知道了</span></button></div>');
return;
}
const accessOpts = accessNames.map((n) =>
'<option value="' + esc(n) + '"' + (cur.dns === n ? ' selected' : '') + '>' + esc(n) + '</option>').join('');
modal(isNew ? '新增域名' : '编辑「' + name + '」',
'<label class="field"><span class="field-k">主域名</span>' +
'<input id="sdName" value="' + esc(cur.name) + '" placeholder="usj.cc"' + (isNew ? '' : ' readonly') + '></label>' +
'<label class="field"><span class="field-k">SAN(证书覆盖的域名,逗号分隔;泛域名写 *.usj.cc)</span>' +
'<input id="sdSan" value="' + esc((cur.san || []).join(', ')) + '" placeholder="usj.cc, *.usj.cc"></label>' +
'<label class="field"><span class="field-k">DNS 凭据(负责写 _acme-challenge 记录)</span>' +
'<select id="sdDns">' + accessOpts + '</select></label>' +
'<label class="field"><span class="field-k">部署目标</span>' +
SSL_DEPLOYS.map((t) =>
'<label style="display:inline-flex;align-items:center;gap:5px;margin-right:14px;font-size:12px">' +
'<input type="checkbox" id="sdDeploy_' + t.v + '"' + ((cur.deploy || []).includes(t.v) ? ' checked' : '') +
' style="width:auto">' + esc(t.label) + '</label>').join('') +
'</label>' +
'<label class="field"><span class="field-k">多吉云 CDN 要绑的域名(逗号分隔,留空 = 不部署到多吉云)</span>' +
'<input id="sdDoge" value="' + esc((cur.dogecloud_domains || []).join(', ')) + '" placeholder="usj.cc"></label>' +
'<label class="field"><span class="field-k">1Panel 站点名(逗号分隔,留空 = 不部署到 1Panel)</span>' +
'<input id="sdPanel" value="' + esc((cur.one_panel_sites || []).join(', ')) + '" placeholder="artalk.usj.cc, openlist.usj.cc"></label>' +
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:16px">' +
'<input type="checkbox" id="sdOff"' + (cur.disabled ? ' checked' : '') + ' style="width:auto">' +
'停用(保留配置,不参与检查与续期)</label>' +
'<div style="display:flex;gap:10px;justify-content:flex-end">' +
(isNew ? '' : '<button class="btn danger" data-act="ssl-del-domain" data-domain="' + esc(name) + '"><span>删除</span></button>') +
'<button class="btn" data-act="modal-close"><span>取消</span></button>' +
'<button class="btn primary" id="sdSave"><span>保存</span></button>' +
'</div>');
$('#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) =>
'<option value="' + t.v + '"' + (type === t.v ? ' selected' : '') + '>' + esc(t.label) + '</option>').join('');
const fieldHtml = (fields) => fields.map(([k, label]) => {
const shown = cur && cur.fields && cur.fields[k] ? cur.fields[k] : '';
return '<label class="field"><span class="field-k">' + esc(label) + '</span>' +
'<input data-field="' + esc(k) + '" value="' + esc(shown) + '" autocomplete="off" spellcheck="false"' +
(cur ? ' placeholder="留空则不改(现在:' + esc(shown || '空') + ')"' : '') + '></label>';
}).join('');
modal(isNew ? '新增凭据' : '编辑凭据「' + name + '」',
'<label class="field"><span class="field-k">凭据名(域名配置里引用的就是这个名字)</span>' +
'<input id="saName" value="' + esc(name || '') + '"' + (isNew ? '' : ' readonly') + ' placeholder="tencent-usj"></label>' +
'<label class="field"><span class="field-k">类型</span>' +
'<select id="saType">' + typeOpts + '</select></label>' +
'<div id="saFields">' + fieldHtml(def.fields) + '</div>' +
'<label class="field"><span class="field-k">备注(可选)</span>' +
'<input id="saNote" value="' + esc(cur ? cur.note : '') + '" placeholder="如:小赵腾讯云 / 团团腾讯云"></label>' +
'<div class="alert" style="background:var(--surface);border-color:var(--line);color:var(--muted)">' +
'密钥用 TOKEN_SECRET 派生的密钥 AES-GCM 加密后才写进 KV,列表接口只回显尾 4 位。' +
'编辑时留空的字段保持不变 —— 不用为了改个备注重新粘一遍密钥。</div>' +
'<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px">' +
'<button class="btn" data-act="modal-close"><span>取消</span></button>' +
'<button class="btn primary" id="saSave"><span>保存</span></button>' +
'</div>');
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]) =>
'<label class="field"><span class="field-k">' + esc(label) + '</span>' +
'<input data-field="' + esc(k) + '" value="' + esc(keep[k] || (cur && cur.fields && cur.fields[k]) || '') +
'" autocomplete="off" spellcheck="false"></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 : ''),
'<label class="field"><span class="field-k">域名</span>' +
'<input id="siDomain" value="' + esc(domain || '') + '" placeholder="usj.cc"></label>' +
'<label class="field"><span class="field-k">证书链 PEM(fullchain)</span>' +
'<textarea id="siCert" style="min-height:110px;font-family:ui-monospace,Consolas,monospace;font-size:11px" ' +
'placeholder="-----BEGIN CERTIFICATE-----&#10;..."></textarea></label>' +
'<label class="field"><span class="field-k">私钥 PEM(可留空,留空则只做监控不参与部署)</span>' +
'<textarea id="siKey" style="min-height:90px;font-family:ui-monospace,Consolas,monospace;font-size:11px" ' +
'placeholder="-----BEGIN PRIVATE KEY-----&#10;..."></textarea></label>' +
'<label class="field"><span class="field-k">签发方(可选,如 LiteSSL / Let\'s Encrypt)</span>' +
'<input id="siIssuer" placeholder="LiteSSL"></label>' +
'<button class="btn primary block" id="siSave"><span>保存(加密存 KV)</span></button>');
$('#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,
'<div class="alert">握手失败:' + esc(r.error || '未知原因') + '</div>' +
'<p style="color:var(--muted);font-size:12px;margin-bottom:16px">如果这里是超时,也可能是对方 443 没开或者被墙 —— 不一定是证书问题。' +
'Worker 从 Cloudflare 的边缘节点出去,和你在本地看到的未必一样。</p>' +
'<div style="text-align:right"><button class="btn" data-act="modal-close"><span>关闭</span></button></div>');
return;
}
modal('实测 ' + host,
'<dl class="kv">' +
'<dt>剩余天数</dt><dd class="' + (r.daysLeft != null && r.daysLeft > 30 ? 'ok' : 'bad') + '">' +
(r.daysLeft == null ? '—' : r.daysLeft + ' 天') + '</dd>' +
'<dt>到期时间</dt><dd>' + esc(r.notAfterText || '—') + '</dd>' +
'<dt>签发方</dt><dd>' + esc(r.issuer || '—') + '</dd>' +
'<dt>主体</dt><dd>' + esc(r.subject || '—') + '</dd>' +
'<dt>覆盖域名</dt><dd>' + esc((r.altNames || []).join(' · ') || '—') + '</dd>' +
'</dl>' +
(r.error ? '<div class="alert" style="margin-top:14px;background:var(--danger-soft);border-color:var(--danger-line);color:var(--danger)">' +
esc(r.error) + '</div>' : '') +
'<div style="text-align:right;margin-top:16px"><button class="btn" data-act="modal-close"><span>关闭</span></button></div>');
}
// ============================================================ 事件委托
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() ? ' <span class="tag" style="font-size:10px">编辑</span>' : '');
(isEditor()
? ' <span class="tag" style="font-size:10px">编辑</span>'
: isSslOnly() ? ' <span class="tag ssl" style="font-size:10px">SSL</span>' : '');
$('#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));
}