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

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

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

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

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

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

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

No files matched your search

+70 -9
View File
@@ -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 相关的转发路径外一律拒。
// ★ 放在所有业务分支之前,且用「允许清单」而不是「排除清单」——
// 将来新增文章相关路由时,SS​L 角色默认是**被拒**的,不会悄悄放行。
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 });
+20 -5
View File
@@ -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();