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:
1 parent
ca2fb7163d
commit
a40a92f526
16 files changed
+2791
-59
No files matched your search
@@ -2,6 +2,7 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.selftest-build/
|
||||
.selftest-ssl/
|
||||
.mail-build/
|
||||
*.log
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
/* ---------------------------------------------------------- 评论列表 */
|
||||
|
||||
@@ -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----- ..."></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----- ..."></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));
|
||||
}
|
||||
|
||||
@@ -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" 吃掉。
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 证书探针 —— 从**外部**实测域名的证书状态。
|
||||
*
|
||||
* 为什么不信 KV 里自己存的那份:KV 里的是「我们上次签发的」,而线上真实
|
||||
* 挂着的可能是别的 CA 签的、可能是手工换过的、可能部署失败了根本没上。
|
||||
* 只有真去握一次手,才知道读者看到的证书是什么。
|
||||
*
|
||||
* 手段:`https://<host>/.well-known/artalk-cf-probe` 发一次 HEAD 请求,
|
||||
* Cloudflare Workers 的 `fetch` 走 TLS,我们可以从 `Response` 上拿不到
|
||||
* 证书 —— 所以换一条路:**用一个只回证书信息的第三方回显接口**并不存在,
|
||||
* 于是这里退一步,只用 `fetch` 的成败 + `cf-*` 不可得的信息做「可达性」判断,
|
||||
* 真正的证书详情(到期日/签发方)由 **KV 里的记录** + 手动上传/续期时的
|
||||
* 解析结果提供。
|
||||
*
|
||||
* ★ 实际上 Workers **确实**提供了 `fetch` 的 TLS 信息:通过
|
||||
* `https://<host>` 建立连接后,响应头里没有证书字段;但 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<TlsInfo> {
|
||||
const h = String(host || '').trim().toLowerCase();
|
||||
if (!h || !/^[a-z0-9.*-]+$/.test(h)) return { ok: false, error: '域名不合法' };
|
||||
|
||||
let socket: { opened: Promise<unknown>; 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<unknown>;
|
||||
close: () => void;
|
||||
getPeerCertificate?: () => unknown;
|
||||
};
|
||||
socket = raw;
|
||||
|
||||
const timeout = new Promise<never>((_, 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);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 证书管家的**唯一数据入口** —— 所有 KV 读写都收在这里。
|
||||
*
|
||||
* 为什么不直接在路由里 `env.RSS_KV.get(...)`:证书管家的数据分四类
|
||||
* (域名配置 / 凭据 / 证书 / 日志),每类的加密要求、缺省值、迁移逻辑都不一样。
|
||||
* 散在路由里写,迟早出现「某处读配置忘了兜默认值 → 线上配置被空对象覆盖」。
|
||||
*
|
||||
* KV 键位(复用已有的 RSS_KV namespace,不新建 —— 免费额度共用一份就够):
|
||||
* certkeeper:config 域名配置 + 通知设置 明文(无敏感信息)
|
||||
* certkeeper:access:<name> 单条凭据 ★ AES-GCM 密文
|
||||
* certkeeper:cert:<domain> 签发的证书与私钥 ★ 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<string[]> {
|
||||
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<KeeperConfig> {
|
||||
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<KeeperConfig> {
|
||||
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<void> {
|
||||
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<AccessRecord | null> {
|
||||
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<AccessRecord>(env, raw);
|
||||
}
|
||||
|
||||
export async function delAccess(env: Env, name: string): Promise<void> {
|
||||
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<string, string>; unreadable?: boolean }[]
|
||||
> {
|
||||
const names = await listKeys(env, 'access:');
|
||||
const out: {
|
||||
name: string;
|
||||
type: string;
|
||||
note: string;
|
||||
fields: Record<string, string>;
|
||||
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<AccessRecord>(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<string, string> = {};
|
||||
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<void> {
|
||||
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<CertRecord | null> {
|
||||
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<CertRecord>(env, raw);
|
||||
} catch {
|
||||
// 密钥换了之后老密文解不开。当作「没有这张证书」比让整个列表接口 500 好,
|
||||
// 上层看到「无证书」会提示重签,实际也是对的处置。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function delCert(env: Env, domain: string): Promise<void> {
|
||||
await env.RSS_KV.delete(P + 'cert:' + String(domain || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
export async function listCertNames(env: Env): Promise<string[]> {
|
||||
return listKeys(env, 'cert:');
|
||||
}
|
||||
|
||||
// ==================================================================== 日志
|
||||
|
||||
/**
|
||||
* 追加一条执行日志(环形缓冲,最多 200 条)。
|
||||
*
|
||||
* ★ 这个函数是**并发不安全**的(读-改-写)。但日志丢一条无关紧要,
|
||||
* 绝不能为了它引入锁或者 D1 表 —— 证书续期一天才跑一次。
|
||||
* 真正的账要记在「证书本身」上(cert:<domain> 的 updatedAt),日志只做参考。
|
||||
*/
|
||||
export async function appendLog(env: Env, entry: LogEntry): Promise<void> {
|
||||
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<LogEntry[]> {
|
||||
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<void> {
|
||||
await env.RSS_KV.delete(P + 'log');
|
||||
}
|
||||
@@ -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.<iv_b64>.<ct_b64>`。带版本前缀是为了将来换算法时能识别老数据。
|
||||
*
|
||||
* ★ 别把这里的东西拿去加密用户密码 —— 那是 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<CryptoKey> {
|
||||
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.<iv>.<ct>` 密文字符串(可直接落 KV) */
|
||||
export async function sealJson(env: Env, value: unknown): Promise<string> {
|
||||
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<T>(env: Env, sealed: string): Promise<T> {
|
||||
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<T>(env: Env, sealed: string): Promise<T | null> {
|
||||
try {
|
||||
return await openJson<T>(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);
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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<SslIdentity | null> {
|
||||
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<string, any> } | { 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<void> {
|
||||
await appendLog(ctx.env, { at: now(), level, action, domain, message: `${ident.name}: ${message}` });
|
||||
}
|
||||
|
||||
// ==================================================================== 概览
|
||||
|
||||
/**
|
||||
* 证书总览 —— 后台首屏用。
|
||||
* 每个域名给:配置 / 库里的记录 / **实测**状态三份信息,前端能一眼看出
|
||||
* 「配了没」「有证没」「线上挂的到底是不是这张」。
|
||||
*/
|
||||
export async function overview(ctx: Ctx): Promise<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<string>();
|
||||
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<boolean> {
|
||||
return (await env.RSS_KV.get('certkeeper:access:' + name)) !== null;
|
||||
}
|
||||
|
||||
// ==================================================================== 凭据
|
||||
|
||||
export async function accessList(ctx: Ctx): Promise<Response> {
|
||||
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<Response> {
|
||||
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<string, string> = {};
|
||||
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<AccessRecord | null> {
|
||||
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<AccessRecord>(env, raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function accessDelete(ctx: Ctx): Promise<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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 `<tr><td style="padding:6px 10px;border-bottom:1px solid #eee">${esc(r.domain)}</td>
|
||||
<td style="padding:6px 10px;border-bottom:1px solid #eee;color:${color};font-weight:600">${text}</td></tr>`;
|
||||
};
|
||||
return `<div style="font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif;font-size:14px;color:#333">
|
||||
<h2 style="font-size:16px;margin:0 0 12px">证书到期汇总</h2>
|
||||
<table style="border-collapse:collapse;width:100%;max-width:520px">
|
||||
<thead><tr><th align="left" style="padding:6px 10px;border-bottom:2px solid #ddd">域名</th>
|
||||
<th align="left" style="padding:6px 10px;border-bottom:2px solid #ddd">状态</th></tr></thead>
|
||||
<tbody>${rows.map(line).join('')}</tbody>
|
||||
</table>
|
||||
<p style="color:#888;font-size:12px;margin-top:14px">由 api.200181.xyz 的证书管家发出 · 提前提醒阈值 ${warnDays} 天</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function esc(s: unknown): string {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (m) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m] as string),
|
||||
);
|
||||
}
|
||||
|
||||
// ==================================================================== 日志
|
||||
|
||||
export async function logList(ctx: Ctx): Promise<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> }[] = [
|
||||
{ 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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 项。
|
||||
*
|
||||
* 踩坑记录:
|
||||
* · 剥 <script> 时必须**保留 admin.js** 的标签,否则后续 mock 注入的正则
|
||||
* 匹配不到任何东西 → 页面一个脚本都不加载 → 全白 + `state is not defined`
|
||||
* · admin.js 是 IIFE,state/renderTabs/go 都不在全局上,别想着从控制台直接摸
|
||||
*
|
||||
* 用法:node tools/probe-ssl-ui.mjs
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, '..');
|
||||
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
|
||||
const PORT = 9418;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ---------------------------------------------------------------- mock 数据
|
||||
const DAY = 86400000;
|
||||
const MOCK = {
|
||||
'/api/v2/ssl/overview': {
|
||||
domains: [
|
||||
{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tencent-usj', deploy: ['dogecloud', '1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 61 * DAY, expireText: '2026-12-07 00:00:00', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 61, level: 'ok' },
|
||||
{ name: 't-t.live', san: ['t-t.live', '*.t-t.live'], dns: 'tencent-tt', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 82 * DAY, expireText: '2026-12-27 00:00:00', issuer: "Let's Encrypt", updatedAt: Date.now(), daysLeft: 82, level: 'ok' },
|
||||
{ name: '200181.xyz', san: ['200181.xyz', '*.200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 19 * DAY, expireText: '2026-10-25 00:00:00', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 19, level: 'warn' },
|
||||
{ name: 'new.example.com', san: ['new.example.com'], dns: 'cloudflare', deploy: [], disabled: false, hasCert: false, expireAt: null, expireText: null, issuer: '', updatedAt: null, daysLeft: null, level: 'none' },
|
||||
],
|
||||
orphans: ['old.example.org'],
|
||||
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
|
||||
mailEnabled: true,
|
||||
serverTime: '2026-10-06 12:00:00',
|
||||
},
|
||||
'/api/v2/ssl/access': {
|
||||
items: [
|
||||
{ name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } },
|
||||
{ name: 'cloudflare', type: 'cloudflare', note: '200181.xyz 的 DNS', fields: { apiToken: 'ALTp********9xQz' } },
|
||||
{ name: 'dogecloud', type: 'dogecloud', note: '', fields: { accessKey: 'dk1a********8f00', secretKey: 'mmN7****11bc' } },
|
||||
{ name: 'old-broken', type: '', note: '', fields: {}, unreadable: true },
|
||||
],
|
||||
types: ['tencentcloud', 'cloudflare', 'dogecloud', '1panel', 'acme-eab'],
|
||||
},
|
||||
'/api/v2/ssl/log': {
|
||||
items: [
|
||||
{ at: Date.now() - 60000, level: 'info', action: 'check', message: '群林: 检查 3 个域名,2 个一致,1 个需关注' },
|
||||
{ at: Date.now() - 3600000, level: 'warn', action: 'check', message: '群林: 200181.xyz 剩余 19 天,低于阈值 30' },
|
||||
{ at: Date.now() - 86400000, level: 'info', action: 'config', message: '群林: 保存配置:3 个域名,提醒邮箱 1 个' },
|
||||
{ at: Date.now() - 172800000, level: 'error', action: 'notify', message: '群林: 邮件未配置(缺少 RESEND_API_KEY)' },
|
||||
],
|
||||
},
|
||||
'/api/v2/ssl/certs': { items: [], warnDays: 30 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 造静态页
|
||||
const work = mkdtempSync(join(tmpdir(), 'ssl-ui-'));
|
||||
const pubDir = join(work, 'public');
|
||||
mkdirSync(join(pubDir, 'admin'), { recursive: true });
|
||||
cpSync(join(root, 'public/admin/admin.css'), join(pubDir, 'admin/admin.css'));
|
||||
cpSync(join(root, 'public/admin/admin.js'), join(pubDir, 'admin/admin.js'));
|
||||
|
||||
// 真实的 admin.html 骨架。剥掉外链脚本时**必须放过 admin.js**(负向先行),
|
||||
// 否则后面往里注 mock 的正则会匹配不到 —— 这是上一版全白的根因。
|
||||
const realHtml = readFileSync(join(root, 'public/admin/index.html'), 'utf8');
|
||||
const html = realHtml.replace(
|
||||
/<script(?![^>]*admin\.js)[^>]*\bsrc=["'][^"']*["'][^>]*><\/script>/g,
|
||||
'',
|
||||
);
|
||||
|
||||
// mock 注入脚本:在 admin.js 之前执行,把 fetch 换成路由表。
|
||||
// /api/v2/user 动态读 localStorage —— 角色切换测试全靠它(改完 reload 即走真实 boot)。
|
||||
const mockJs = `
|
||||
(function () {
|
||||
const MOCK = ${JSON.stringify(MOCK)};
|
||||
const ok = (obj, status) => new Response(JSON.stringify(obj), {
|
||||
status: status || 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
window.fetch = function (input, init) {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
let path = url;
|
||||
try { path = new URL(url, location.href).pathname; } catch (e) {}
|
||||
if (path === '/api/v2/user') {
|
||||
let u = null;
|
||||
try { u = JSON.parse(localStorage.getItem('atk.user') || 'null'); } catch (e) {}
|
||||
return Promise.resolve(ok({ user: u, is_login: !!u }));
|
||||
}
|
||||
// 让 /admin/session 明确返回「不是国内机模式」,走 Cloudflare 分支
|
||||
if (path.endsWith('/admin/session')) {
|
||||
return Promise.resolve(new Response('{"msg":"Not Found"}', { status: 404, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
if (MOCK[path]) return Promise.resolve(ok(MOCK[path]));
|
||||
return Promise.resolve(ok({}));
|
||||
};
|
||||
// 预置一个已登录的管理员会话,跳过登录门。
|
||||
// ★ 只在缺省时写入 —— 角色切换测试靠「改 atk.user → reload」,
|
||||
// 如果这里每次都重置,reload 后角色又被改回管理员了。
|
||||
if (!localStorage.getItem('atk.token')) localStorage.setItem('atk.token', 'fake-admin-token');
|
||||
if (!localStorage.getItem('atk.user')) localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' }));
|
||||
})();
|
||||
`;
|
||||
writeFileSync(join(pubDir, 'admin', 'mock.js'), mockJs);
|
||||
|
||||
// 注入 mock.js(必须排在 admin.js 前面)
|
||||
const finalHtml = html.replace(
|
||||
/(<script[^>]*src=["'][^"']*admin\.js["'][^>]*><\/script>)/,
|
||||
'<script src="./mock.js"></script>\n$1',
|
||||
);
|
||||
if (finalHtml === html) throw new Error('mock 注入失败:没找到 admin.js 的 script 标签');
|
||||
writeFileSync(join(pubDir, 'admin', 'index.html'), finalHtml);
|
||||
|
||||
// ---------------------------------------------------------------- 起服务 + CDP
|
||||
const { createServer } = await import('node:http');
|
||||
const server = createServer((req, res) => {
|
||||
const p = new URL(req.url, 'http://x').pathname;
|
||||
const file = p === '/' || p === '/admin/' || p === '/admin' ? '/admin/index.html' : p;
|
||||
const full = join(pubDir, file);
|
||||
try {
|
||||
const buf = readFileSync(full);
|
||||
const ext = full.slice(full.lastIndexOf('.'));
|
||||
const ct = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': ct });
|
||||
res.end(buf);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('nf');
|
||||
}
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const pagePort = server.address().port;
|
||||
const URL_ = `http://127.0.0.1:${pagePort}/admin/`;
|
||||
|
||||
const userDir = mkdtempSync(join(tmpdir(), 'cdp-ssl-'));
|
||||
const chrome = spawn(CHROME, [
|
||||
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
|
||||
'--no-first-run', '--disable-gpu', '--window-size=1320,1500', 'about:blank',
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
let fail = 0;
|
||||
const results = [];
|
||||
const t = (name, ok, extra) => {
|
||||
results.push((ok ? ' ✓ ' : ' ✗ ') + name + (ok || !extra ? '' : ' → ' + extra));
|
||||
if (!ok) fail++;
|
||||
};
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
|
||||
await sleep(300);
|
||||
}
|
||||
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
|
||||
const ws = new WebSocket(tab.webSocketDebuggerUrl);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
|
||||
});
|
||||
await new Promise((res) => ws.addEventListener('open', res));
|
||||
const send = (method, params = {}) => new Promise((res) => {
|
||||
const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params }));
|
||||
});
|
||||
const ev = async (expr) => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
|
||||
if (r.result && r.result.exceptionDetails) return 'EXC: ' + JSON.stringify(r.result.exceptionDetails.exception);
|
||||
return r.result?.result?.value ?? null;
|
||||
};
|
||||
|
||||
// 收集页面错误 —— 这是「画不出来」类 bug 的第一手证据
|
||||
const errs = [];
|
||||
ws.addEventListener('message', (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.method === 'Runtime.exceptionThrown') {
|
||||
errs.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
}
|
||||
});
|
||||
await send('Runtime.enable');
|
||||
await send('Page.enable');
|
||||
|
||||
await sleep(3500);
|
||||
|
||||
// ---------- 基本渲染(管理员,走真实 boot)
|
||||
t('登录门已隐藏', (await ev(`document.querySelector('#gate').hidden`)) === true);
|
||||
t('主界面已显示', (await ev(`document.querySelector('#app').hidden`)) === false);
|
||||
t('身份栏是「群林」', String(await ev(`document.querySelector('#who')?.textContent`)).includes('群林'));
|
||||
|
||||
// ---------- 导航里能看到「证书管家」(管理员全量菜单)
|
||||
const navText = await ev(`document.querySelector('#tabs').textContent`);
|
||||
t('导航含「证书管家」', String(navText).includes('证书管家'), String(navText));
|
||||
|
||||
// ---------- 点进证书管家
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); return !!b; })()`);
|
||||
await sleep(2200);
|
||||
|
||||
t('顶部标题为「证书管家」', String(await ev(`document.querySelector('.page-head h2')?.textContent`)).includes('证书管家'));
|
||||
const view = String(await ev(`document.querySelector('#view').innerHTML`));
|
||||
|
||||
t('域名表格已渲染', view.includes('usj.cc') && view.includes('t-t.live'));
|
||||
t('SAN 显示出来了', view.includes('*.usj.cc'));
|
||||
t('签发方显示出来了', view.includes('LiteSSL'));
|
||||
t('临期域名有告警横幅', view.includes('200181.xyz') && view.includes('临期'));
|
||||
t('待登记的域名有「无记录」标记', view.includes('new.example.com'));
|
||||
t('孤儿记录提示出现', view.includes('old.example.org'));
|
||||
|
||||
// 剩余天数的数值
|
||||
t('剩余天数渲染为数字', /61/.test(view) && /19/.test(view));
|
||||
t('「实测」按钮存在', view.includes('ssl-probe'));
|
||||
t('「登记」按钮存在', view.includes('ssl-import'));
|
||||
t('「+ 域名」按钮存在', view.includes('ssl-add-domain'));
|
||||
|
||||
// ---------- 凭据区
|
||||
await sleep(600);
|
||||
const acc = String(await ev(`document.querySelector('#ssl-access')?.innerHTML`));
|
||||
t('凭据列表已渲染', acc.includes('tencent-usj') && acc.includes('cloudflare'));
|
||||
t('★ 凭据只显示脱敏值', acc.includes('AKID********3f2a') && !acc.includes('AKIDabcdefg'));
|
||||
t('解不开的凭据被标出来', acc.includes('old-broken') && acc.includes('读不出'));
|
||||
t('凭据有编辑/删除按钮', acc.includes('ssl-access-edit') && acc.includes('ssl-access-del'));
|
||||
|
||||
// ---------- 日志区
|
||||
const logHtml = String(await ev(`document.querySelector('#ssl-log')?.innerHTML`));
|
||||
t('执行日志已渲染', logHtml.includes('检查 3 个域名') && logHtml.includes('低于阈值'));
|
||||
|
||||
// ---------- 通知设置
|
||||
const notify = String(await ev(`document.querySelector('#ssl-notify-cfg')?.innerHTML`));
|
||||
t('通知收件人回显', notify.includes('177018615@qq.com'));
|
||||
t('提醒天数回显', notify.includes('value="30"'));
|
||||
|
||||
// ---------- 打开「+ 域名」弹窗
|
||||
await ev(`document.querySelector('[data-act="ssl-add-domain"]').click()`);
|
||||
await sleep(900);
|
||||
const modalTitle = await ev(`document.querySelector('#modalTitle')?.textContent`);
|
||||
t('「+ 域名」弹窗打开了', String(modalTitle).includes('新增域名'), String(modalTitle));
|
||||
const modalBody = String(await ev(`document.querySelector('#modalBody')?.innerHTML`));
|
||||
t('弹窗里有 DNS 凭据下拉', modalBody.includes('sdDns') && modalBody.includes('tencent-usj'));
|
||||
t('弹窗里有部署目标复选', modalBody.includes('sdDeploy_dogecloud') && modalBody.includes('sdDeploy_1panel'));
|
||||
t('弹窗里有 SAN 输入', modalBody.includes('sdSan'));
|
||||
await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`);
|
||||
await sleep(400);
|
||||
|
||||
// ---------- 打开凭据编辑弹窗(含脱敏回显)
|
||||
await ev(`document.querySelector('[data-act="ssl-access-edit"][data-name="tencent-usj"]').click()`);
|
||||
await sleep(900);
|
||||
const accModal = String(await ev(`document.querySelector('#modalBody')?.innerHTML`));
|
||||
t('凭据弹窗打开了', accModal.includes('saName') && accModal.includes('saType'));
|
||||
t('★ 凭据弹窗里回显的是脱敏值', accModal.includes('AKID********3f2a'));
|
||||
t('凭据弹窗提示「留空则不改」', accModal.includes('留空则不改') || accModal.includes('留空'));
|
||||
await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`);
|
||||
await sleep(400);
|
||||
|
||||
// ---------- 页面无 JS 异常(管理员阶段)
|
||||
const realErrs = errs.filter((e) => !/favicon|ERR_|Failed to load resource/i.test(String(e)));
|
||||
t('★ 页面无未捕获 JS 异常', realErrs.length === 0, realErrs.slice(0, 3).join(' | '));
|
||||
|
||||
// ---------- ★ SSL 专属角色:改 localStorage → 真实 reload → 走真实 boot
|
||||
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 9, name: '阿美', is_admin: false, role: 'ssl' })); 'ok'`);
|
||||
await send('Page.reload').catch(() => {});
|
||||
await sleep(3000);
|
||||
const sslAppShown = (await ev(`document.querySelector('#app').hidden`)) === false;
|
||||
t('★ SSL 角色能进主界面', sslAppShown);
|
||||
const sslTabs = String(await ev(`document.querySelector('#tabs')?.textContent`));
|
||||
t('★ SSL 角色只看到「证书管家」', sslAppShown && sslTabs.includes('证书管家') && !sslTabs.includes('评论管理') && !sslTabs.includes('文章编辑') && !sslTabs.includes('仪表盘'), sslTabs);
|
||||
const sslView = String(await ev(`document.querySelector('#view').innerHTML`));
|
||||
t('★ SSL 角色落地页就是证书管家', sslAppShown && sslView.includes('usj.cc'), sslView.slice(0, 120));
|
||||
const sslWho = String(await ev(`document.querySelector('#who')?.textContent`));
|
||||
t('★ 身份栏标了 SSL', sslWho.includes('阿美') && sslWho.includes('SSL'), sslWho);
|
||||
|
||||
// ---------- ★ 编辑角色:看不到证书管家
|
||||
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 8, name: '小明', is_admin: false, role: 'editor' })); 'ok'`);
|
||||
await send('Page.reload').catch(() => {});
|
||||
await sleep(3000);
|
||||
const edAppShown = (await ev(`document.querySelector('#app').hidden`)) === false;
|
||||
const edTabs = String(await ev(`document.querySelector('#tabs')?.textContent`));
|
||||
t('★ 编辑角色进得去主界面', edAppShown);
|
||||
t('★ 编辑角色看得到「文章编辑」', edAppShown && edTabs.includes('文章编辑'), edTabs);
|
||||
t('★ 编辑角色看不到「证书管家」', !edTabs.includes('证书管家'), edTabs);
|
||||
|
||||
// ---------- 截图(人眼复核)
|
||||
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' })); 'ok'`);
|
||||
await send('Page.reload').catch(() => {});
|
||||
await sleep(3000);
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`);
|
||||
await sleep(2000);
|
||||
await ev(`document.documentElement.setAttribute('data-theme','light')`);
|
||||
await sleep(500);
|
||||
{
|
||||
const s = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
|
||||
const out = join(root, '..', '.editor-tmp', 'ssl-panel-light.png');
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, Buffer.from(s.result.data, 'base64'));
|
||||
console.log('\n截图:' + out);
|
||||
}
|
||||
|
||||
console.log('\n' + results.join('\n'));
|
||||
console.log('\n' + '='.repeat(56));
|
||||
console.log(fail ? `失败 ${fail} 项` : '全部通过 ✓');
|
||||
ws.close();
|
||||
} finally {
|
||||
chrome.kill();
|
||||
server.close();
|
||||
}
|
||||
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -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: '<script>alert(1)</script>.cc', days: -2 }], 30);
|
||||
t('★ 邮件里域名被 HTML 转义', !html.includes('<script>') && html.includes('<script>'));
|
||||
t('邮件含「已过期」文案', html.includes('已过期'));
|
||||
}
|
||||
|
||||
// ================================================================ 汇总
|
||||
console.log('\n' + '='.repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
if (fail) {
|
||||
console.log('\n失败清单:');
|
||||
for (const f of failures) console.log(' · ' + f);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('全部通过 ✓');
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
+70
-9
@@ -138,7 +138,7 @@ function loadSessions() {
|
||||
sessions.set(sid, {
|
||||
uid: String(v.uid || ''),
|
||||
name: String(v.name || v.user || cfg.adminUser),
|
||||
role: v.role === 'editor' ? 'editor' : 'admin',
|
||||
role: normalizeRole(v.role),
|
||||
exp: v.exp,
|
||||
});
|
||||
}
|
||||
@@ -207,8 +207,8 @@ function decodeHeader(v) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 谁在操作 —— 返回 { uid, name, role } 或 null。role 只有 'admin' / 'editor'
|
||||
* 两种(普通评论用户进不来写作后台)。
|
||||
* 谁在操作 —— 返回 { uid, name, role } 或 null。
|
||||
* role 有三档:'admin'(全部)/ 'editor'(只有自己的文章)/ 'ssl'(只有证书管家)。
|
||||
*
|
||||
* 两条通道:
|
||||
* ① Cloudflare Worker 反代:带了共享令牌 X-Editor-Token(浏览器永远拿不到),
|
||||
@@ -228,7 +228,7 @@ function identify(req) {
|
||||
return {
|
||||
uid: String(s.uid || ''),
|
||||
name: String(s.name || cfg.adminUser),
|
||||
role: s.role === 'editor' ? 'editor' : 'admin',
|
||||
role: normalizeRole(s.role),
|
||||
};
|
||||
}
|
||||
// Worker 反代通道:由 Cloudflare 发出,不带浏览器 Cookie,只有令牌 + 身份头
|
||||
@@ -236,12 +236,19 @@ function identify(req) {
|
||||
return {
|
||||
uid: String(req.headers['x-editor-uid'] || '').trim(),
|
||||
name: decodeHeader(req.headers['x-editor-user']),
|
||||
role: String(req.headers['x-editor-role'] || '').trim() === 'editor' ? 'editor' : 'admin',
|
||||
role: normalizeRole(req.headers['x-editor-role']),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 角色归一化:只有这三个值合法;空/未知一律按最高权限处理(保持改造前语义) */
|
||||
function normalizeRole(v) {
|
||||
const r = String(v || '').trim();
|
||||
if (r === 'editor' || r === 'ssl') return r;
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 直连后台登录时,把账号密码交给 Cloudflare 上的用户表验证。
|
||||
*
|
||||
@@ -280,8 +287,16 @@ async function loginViaCf(identifier, password) {
|
||||
const u = d && d.user;
|
||||
if (!u) return { denied: true };
|
||||
|
||||
const role = u.role === 'editor' ? 'editor' : u.is_admin || u.role === 'admin' ? 'admin' : '';
|
||||
if (!role) return { denied: true }; // 普通评论用户没有写作后台权限
|
||||
// ★ 三种角色都要认。早期版本只认 editor/admin,SSL 管理员登进来会被当成
|
||||
// 普通评论用户直接拒掉 —— 那它就没法从国内线路用证书管家了。
|
||||
const role = u.is_admin
|
||||
? 'admin'
|
||||
: u.role === 'editor'
|
||||
? 'editor'
|
||||
: u.role === 'ssl'
|
||||
? 'ssl'
|
||||
: '';
|
||||
if (!role) return { denied: true }; // 普通评论用户没有后台权限
|
||||
return { ident: { uid: String(u.id), name: String(u.name || identifier), role } };
|
||||
}
|
||||
|
||||
@@ -337,6 +352,23 @@ const RSS_PATH_PREFIXES = (env.RSS_PATHS || '/api/feeds,/api/links,/api/link-app
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* 需要转给**证书管家**的路径前缀(后台的证书管家模块用)。
|
||||
*
|
||||
* 为什么证书管家的实现放在 Cloudflare 而不在本机:
|
||||
* · 它握着 AES-GCM 保险箱的密钥(TOKEN_SECRET)和全部云厂商凭据,
|
||||
* 再多一处副本就多一个失守点;
|
||||
* · 逻辑(探测、ACME、部署)与 artalk-cf 共用同一套 KV 与后台会话。
|
||||
* 本机只做一跳转发,语义与 rssProxy 完全一致。
|
||||
*
|
||||
* ★ 与 RSS 的区别:**SSL 管理员也要能走**(证件管家就是给它用的),
|
||||
* 所以这条路径的放行判定是 role ∈ {admin, ssl},不是 admin-only。
|
||||
*/
|
||||
const SSL_PATH_PREFIXES = (env.SSL_PATHS || '/api/v2/ssl')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* 把订阅/友链请求原样转发到订阅服务(Cloudflare 上的 rss-api),带上共享令牌。
|
||||
* 用途:国内机后台的订阅源 / 友链模块 —— 请求由本机出去,前端不需要知道
|
||||
@@ -526,7 +558,7 @@ const server = http.createServer(async (req, res) => {
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
mode: 'direct',
|
||||
user: { id: s.uid || null, name: s.name || cfg.adminUser, role: s.role === 'editor' ? 'editor' : 'admin' },
|
||||
user: { id: s.uid || null, name: s.name || cfg.adminUser, role: normalizeRole(s.role) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -602,7 +634,9 @@ const server = http.createServer(async (req, res) => {
|
||||
res.end('handoff rejected');
|
||||
};
|
||||
if (!/^\d{13}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 120_000) return bad();
|
||||
if (role !== 'admin' && role !== 'editor') return bad();
|
||||
// ★ 三种角色都要收 —— 早期只认 admin/editor,SSL 管理员点「国内线路」
|
||||
// 会被这里静默拒掉(403 handoff rejected),看起来像「按钮坏了」。
|
||||
if (role !== 'admin' && role !== 'editor' && role !== 'ssl') return bad();
|
||||
if (!name) return bad();
|
||||
const expect = hmacHex(cfg.token, `${ts}\n${uid}\n${name}\n${role}`);
|
||||
if (!expect || !safeEqual(sig, expect)) return bad();
|
||||
@@ -629,6 +663,21 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// SSL 管理员只该碰证书管家。白名单式放行:除 /ssl 相关的转发路径外一律拒。
|
||||
// ★ 放在所有业务分支之前,且用「允许清单」而不是「排除清单」——
|
||||
// 将来新增文章相关路由时,SSL 角色默认是**被拒**的,不会悄悄放行。
|
||||
if (me.role === 'ssl') {
|
||||
const allow =
|
||||
url.pathname === '/health' ||
|
||||
url.pathname === '/admin/session' ||
|
||||
url.pathname === '/admin/logout' ||
|
||||
SSL_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'));
|
||||
if (!allow) {
|
||||
json(res, 403, { error: 'SSL 管理员只能使用证书管家' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。
|
||||
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
|
||||
// ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。
|
||||
@@ -641,6 +690,18 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 证书管家:同样反代到 Cloudflare(实现和密钥都在那边)。
|
||||
// ★ 放行 admin + ssl —— 这条路径本来就是为了让 SSL 管理员用的。
|
||||
// 注意判定是**枚举放行**,不是 `!== 'editor'`:以后加角色不会静默放权。
|
||||
if (SSL_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) {
|
||||
if (me.role !== 'admin' && me.role !== 'ssl') {
|
||||
json(res, 403, { error: '需要管理员或 SSL 管理员权限' });
|
||||
return;
|
||||
}
|
||||
await rssProxy(req, res, url);
|
||||
return;
|
||||
}
|
||||
|
||||
const hit = match(req.method, url.pathname);
|
||||
if (!hit) {
|
||||
json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname });
|
||||
|
||||
@@ -123,12 +123,19 @@ export function makePosts(cfg) {
|
||||
// ------------------------------------------------------------ 归属判定
|
||||
//
|
||||
// viewer = { uid, name, role } —— 由 server.mjs 从请求头/会话里解出来。
|
||||
// admin 不受限;editor 只能碰自己的。
|
||||
// 三档角色(与 Cloudflare 侧 users.role 一一对应):
|
||||
// admin 管理员 —— 不受限
|
||||
// editor 编辑 —— 只能碰自己写的文章
|
||||
// ssl SSL 管理员 —— ★ 压根不该出现在这里:文章相关的路由入口处
|
||||
// 已经按角色拦掉了(见 server.mjs)。万一漏了,
|
||||
// 下面的判定会把它当 editor 处理 —— 「只看得见自己
|
||||
// 名下的东西」,而不是当 admin 全量放行。
|
||||
// 判定的写法是「谁受限」而不是「谁不受限」,就是为了这一刻的兜底方向正确。
|
||||
// viewer 为 null/undefined = 内部调用(不做限制),只用于本文件内部互相调用。
|
||||
|
||||
function owns(item, viewer) {
|
||||
if (!item || !viewer) return true;
|
||||
if (viewer.role !== 'editor') return true; // 管理员(或未知角色走不到这儿)
|
||||
if (viewer.role === 'admin') return true; // 只有管理员不受限
|
||||
const id = String(item.authorId || '');
|
||||
if (id) return id === String(viewer.uid);
|
||||
// 老文章的兜底:没有 author_id 时按昵称认领(改造前 write-server 写的那些)
|
||||
@@ -142,7 +149,7 @@ export function makePosts(cfg) {
|
||||
|
||||
/** 编辑自己在仓库里的文章目录(git pathspec),管理员返回 null 表示「全量」 */
|
||||
function ownedPaths(viewer) {
|
||||
if (!viewer || viewer.role !== 'editor') return null;
|
||||
if (!viewer || viewer.role === 'admin') return null;
|
||||
return findPostDirs()
|
||||
.filter((dir) => owns(toListItem(dir), viewer))
|
||||
.map(relOf);
|
||||
@@ -151,8 +158,9 @@ export function makePosts(cfg) {
|
||||
function listPosts({ q = '', page = 1, perPage = 20 } = {}, viewer) {
|
||||
let rows = findPostDirs().map(toListItem);
|
||||
|
||||
// 编辑角色:列表只显示自己写的(连「别人有几篇」都不让它知道)
|
||||
if (viewer && viewer.role === 'editor') rows = rows.filter((r) => owns(r, viewer));
|
||||
// 编辑角色(以及任何非管理员):列表只显示自己写的
|
||||
// (连「别人有几篇」都不让它知道)
|
||||
if (viewer && viewer.role !== 'admin') rows = rows.filter((r) => owns(r, viewer));
|
||||
|
||||
// slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉
|
||||
const seen = new Map();
|
||||
@@ -374,6 +382,13 @@ export function makePosts(cfg) {
|
||||
}
|
||||
|
||||
function createPost({ frontMatter = {}, content = '' } = {}, viewer) {
|
||||
// ★ 只有管理员和编辑能新建。SSL 管理员没有写作权限 —— 它在 server.mjs
|
||||
// 的路由层就该被拦住;这里再卡一道,是因为「新建」会在仓库里落文件,
|
||||
// 一旦漏放不是「看不到」而是「写进去了」,后果比读取严重得多。
|
||||
if (viewer && viewer.role !== 'admin' && viewer.role !== 'editor') {
|
||||
throw bad('当前角色没有创建文章的权限', 403);
|
||||
}
|
||||
|
||||
const title = String(frontMatter.title || '').trim();
|
||||
if (!title) throw bad('缺少标题');
|
||||
const slug = String(frontMatter.slug || '').trim() || makeSlug();
|
||||
|
||||
+102
-9
@@ -95,20 +95,65 @@ grep securityEntrance/allowIP → 空(未设白名单)
|
||||
│ │ 5. 邮件通知(成功/失败) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [HTTP] 手动触发 /admin/cert/check (带鉴权) │
|
||||
│ /admin/cert/list 查看所有证书状态 │
|
||||
│ [HTTP 后台 /admin] ★ 全部带鉴权 │
|
||||
│ —— 只读 —— │
|
||||
│ GET /admin/cert/list 证书状态(剩余天数/签发方/覆盖域名)│
|
||||
│ GET /admin/config/get 读当前域名配置 │
|
||||
│ GET /admin/log 最近若干次执行日志 │
|
||||
│ —— 写(★ 替代 certimate 的点点点界面)—— │
|
||||
│ POST /admin/config/save 保存域名配置(整份 JSON,带校验) │
|
||||
│ POST /admin/cert/check 手动触发检查(可指定域名) │
|
||||
│ POST /admin/cert/renew 强制续期某个域名 │
|
||||
│ POST /admin/cert/deploy 只重跑部署(不重新签发) │
|
||||
│ POST /admin/access/save 保存/更新凭据(AK/SK 等,加密存储) │
|
||||
│ GET /admin/access/list 凭据列表(**只回显名称+尾4位**) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.0 ★ 配置编辑能力(为什么必须有)
|
||||
|
||||
certimate 的核心价值不只是「自动续期」,还有**能点点点的配置界面**。
|
||||
既然要把它精简掉,这个能力必须补上,否则每次加域名/改部署目标都要去手改 KV,
|
||||
比 certimate 还难用。
|
||||
|
||||
**设计原则:配置即数据,全部走 API 读写,配一个单页后台。**
|
||||
|
||||
| 能力 | 实现 |
|
||||
|---|---|
|
||||
| 查看/新增/删除域名 | `GET/POST /admin/config/*` 读写 KV 里的 `config` |
|
||||
| 每个域名的 SAN、DNS 渠道、部署目标 | 表单编辑,保存前做** Schema 校验** |
|
||||
| 凭据(腾讯云 AK/SK、多吉云、1Panel、EAB) | `POST /admin/access/save`,**加密后存 KV** |
|
||||
| 手动触发(检查/续期/重部署) | 三个 POST 接口,页面上按钮直连 |
|
||||
| 执行日志 | `GET /admin/log`,环形缓冲最后 N 条 |
|
||||
|
||||
**鉴权**:复用 artalk-cf 现成的会话令牌方案(`ADMIN_PASSWORD` + 签名 token),
|
||||
后台路径挂 `/admin`,与公开接口隔离。
|
||||
|
||||
**凭据安全**(★ 重要):
|
||||
- 存 KV 前用 `TOKEN_SECRET` 做 **AES-GCM 加密**,KV 里落的是密文
|
||||
- `GET /admin/access/list` **只回显名称和尾 4 位**(如 `AKID****3f2a`),
|
||||
绝不把明文吐回浏览器;要改就整条重填
|
||||
- 所有写接口校验 `Origin` + 会话,防 CSRF
|
||||
|
||||
**配置的前向兼容**:`config` JSON 带 `version` 字段。
|
||||
未来加字段(如新的 DNS 渠道)时,读取侧按 `version` 做迁移,老配置不会读挂。
|
||||
|
||||
> ★ 一句话:**certimate 有的编辑能力,这里一个不少;它没有的(多吉云自动部署),这里还多了。**
|
||||
|
||||
### 2.1 域名配置(KV 存储,可动态加域名)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1, // ★ 前向兼容:加字段时递增,读取侧按版本迁移
|
||||
"notify": {
|
||||
"emails": ["177018615@qq.com"],// 到期/失败提醒收件人(可在后台改)
|
||||
"daysBefore": 30 // 剩余多少天开始提醒
|
||||
},
|
||||
"domains": [
|
||||
{
|
||||
"name": "usj.cc",
|
||||
"san": ["usj.cc", "*.usj.cc"],
|
||||
"dns": "tencent",
|
||||
"dns": "tencent", // 引用凭据名,见下
|
||||
"deploy": ["dogecloud", "1panel"],
|
||||
"dogecloud_domains": ["usj.cc"], // 绑到多吉云 CDN 的域名
|
||||
"1panel_sites": ["artalk.usj.cc", "openlist.usj.cc", "..."]
|
||||
@@ -131,6 +176,25 @@ grep securityEntrance/allowIP → 空(未设白名单)
|
||||
}
|
||||
```
|
||||
|
||||
**凭据单独存**(KV `access:<name>`,密文),域名配置里只写**名字**引用:
|
||||
|
||||
```jsonc
|
||||
// KV: access:tencent-usj (AES-GCM 加密后存)
|
||||
{ "type": "tencentcloud", "secretId": "AKID...", "secretKey": "..." }
|
||||
|
||||
// KV: access:dogecloud
|
||||
{ "type": "dogecloud", "accessKey": "...", "secretKey": "..." }
|
||||
|
||||
// KV: access:1panel
|
||||
{ "type": "1panel", "apiKey": "...", "serverUrl": "http://119.29.215.187:3721" }
|
||||
|
||||
// KV: access:litessl (ACME EAB,不是私有 API)
|
||||
{ "type": "acme-eab", "directoryUrl": "https://...", "eabKid": "...", "eabHmacKey": "..." }
|
||||
```
|
||||
|
||||
> ★ 拆开的好处:改凭据(如换 API Key)不用碰域名配置;
|
||||
> 加域名时直接复用已有凭据名,不用重复填密钥。
|
||||
|
||||
---
|
||||
|
||||
## 三、各环节实现要点
|
||||
@@ -228,26 +292,46 @@ Cloudflare Worker 的出口 IP 是**海量动态 IP**,无法加白名单。
|
||||
|
||||
### 3.5 存储与通知
|
||||
|
||||
- **KV**:`cert:<domain>` → `{ cert, key, expireAt, updatedAt }`;`config` → 域名配置
|
||||
KV 键位规划:
|
||||
|
||||
| Key | 内容 | 是否加密 |
|
||||
|---|---|---|
|
||||
| `config` | 域名配置 + 通知设置(含 `version`) | 否(无敏感信息) |
|
||||
| `access:<name>` | 凭据(腾讯云/多吉云/1Panel/EAB) | ★ **AES-GCM 加密** |
|
||||
| `cert:<domain>` | 签发的证书 `{ cert, key, expireAt, updatedAt }` | ★ **AES-GCM 加密** |
|
||||
| `log:latest` | 最近 N 条执行记录(环形缓冲) | 否 |
|
||||
|
||||
- **加密**:用 `TOKEN_SECRET` 派生密钥,`crypto.subtle` 做 AES-GCM。
|
||||
私钥落到 KV 必须是密文 —— 万一 KV 泄露不至于直接失守。
|
||||
- **邮件**:Worker 无 TCP socket,**不能直连 SMTP**。
|
||||
现有 worker 已用 **Resend**(HTTP API),复用同一套:
|
||||
`POST https://api.resend.com/emails`,发到 `177018615@qq.com` / `1770186415@qq.com`
|
||||
`POST https://api.resend.com/emails`,收件人从 `config.notify.emails` 读(后台可改)
|
||||
- **crons**:`[triggers] crons = ["30 4 * * *"]`(UTC 4:30 = 北京 12:30)
|
||||
|
||||
---
|
||||
|
||||
## 四、落地路径(分阶段,可随时叫停)
|
||||
|
||||
### Phase 1 —— 只读监控(零风险)
|
||||
### Phase 1 —— 只读监控 + 配置后台(零风险)
|
||||
|
||||
- 部署 Worker,`crons` 每天检查三个域名的证书剩余天数
|
||||
- 到期 < 30 天 → 发邮件提醒
|
||||
- **完全不写任何东西**,纯观察。可以先跑两周验证数据准确性。
|
||||
- **★ 同时把配置后台做出来**(`/admin` 单页):
|
||||
- 证书状态列表(剩余天数、签发方、覆盖域名)
|
||||
- 域名配置的**增删改**(写 KV,但 Phase 1 不参与签发)
|
||||
- 凭据管理(加密存 KV,只回显尾 4 位)
|
||||
- **完全不签发、不部署**,纯观察 + 配置。可以先跑两周验证数据准确性。
|
||||
|
||||
> ★ 把后台放 Phase 1 的理由:它是**纯读写自己的 KV**,不碰任何外部系统,
|
||||
> 零风险却能立刻替代 certimate 的日常操作(加域名、改收件人、看状态)。
|
||||
> 等工作流验证完毕,Phase 2 再让它「动起来」。
|
||||
|
||||
### Phase 2 —— 接管续期(+ 多吉云自动部署)
|
||||
- 实现 ACME(Let's Encrypt 标准协议优先)
|
||||
- DNS-01 走腾讯云 + Cloudflare
|
||||
- 自动部署到**多吉云 CDN**(有正规 API,风险低)
|
||||
- 邮件通知结果;**1Panel 仍由 certimate 管**
|
||||
- 后台新增「手动续期 / 只重部署」按钮
|
||||
|
||||
### Phase 3 —— 接管 1Panel + 关停 certimate
|
||||
- 服务器加 `cert-sync.sh`(从 Worker 拉证书 + reload)
|
||||
@@ -287,7 +371,16 @@ Cloudflare Worker 的出口 IP 是**海量动态 IP**,无法加白名单。
|
||||
| 部署多吉云 | ✅ | ✅ |
|
||||
| 部署 1Panel | ✅ | ✅(走服务器拉取) |
|
||||
| 部署又拍云 | ❌(本来就没配) | ❌(放弃) |
|
||||
| **加/改域名** | 网页点点 | ✅ **网页点点**(`/admin` 后台,见 §2.0) |
|
||||
| **改凭据 / 通知收件人** | 网页点点 | ✅ **网页点点** |
|
||||
| **看执行日志** | ✅ | ✅(`GET /admin/log`) |
|
||||
| 手动触发续期/重部署 | ✅ | ✅(后台按钮) |
|
||||
| 凭据存储 | 明文 SQLite | ★ **AES-GCM 加密** |
|
||||
| 需要服务器 | **✅ 需要** | ❌ **不需要** |
|
||||
| 加域名 | 网页点点 | 改 KV JSON |
|
||||
| 通知 | 邮件 | 邮件 |
|
||||
| 通知 | 邮件 | 邮件(Resend HTTP) |
|
||||
| 成本 | 服务器 | **0**(CF 免费额度) |
|
||||
|
||||
> ★ 结论:**certimate 的编辑能力一项不少**(加域名、改凭据、看日志、手动触发),
|
||||
> 而且额外多了「凭据加密存储」和「多吉云自动部署」。
|
||||
> 唯一少的是 certimate 的「可视化工作流编排」(拖拽节点那种)——
|
||||
> 本项目只有 3 个固定工作流,用配置表单比拖拽更直接,不构成损失。
|
||||
Reference in new issue
Block a user