diff --git a/docs/reports/fsys_cards_plugin_dev_doc.md b/docs/reports/fsys_cards_plugin_dev_doc.md new file mode 100644 index 0000000..68f46d8 --- /dev/null +++ b/docs/reports/fsys_cards_plugin_dev_doc.md @@ -0,0 +1,352 @@ +# 热插拔嵌入式前端页面(服务自带卡片)开发计划 + +- 日期:2026-08-19 +- 状态:待评审(仅开发文档,未实施) +- 关联模块:fsys(前端托管)、dnfs2(试点服务)、core/services.yaml、fsys/public(前端页面) + +## 1. 背景与目标 + +### 1.1 问题 + +社区首页(fsys 托管的 `index.html`)的卡片是**静态写死在 HTML 里的**(`features` 区块、`quick-links` 导航)。新增/下线一个服务入口需要手工改 HTML + CSS + JS 并重启 fsys,不符合微服务"服务自治、即插即用"的定位。 + +### 1.2 目标 + +- **卡片由提供服务的微服务自带**:每个服务在自己的 HTTP 端点上暴露 `GET /cards` 卡片清单(清单协议见 §4)。 +- **动态插入**:fsys 作为聚合器定期拉取各服务卡片清单,通过 `GET /api/cards` 提供给前端;前端动态渲染,**不重启 fsys、不改页面代码**即可上线/下线卡片。 +- **热插拔语义**: + - 服务上线 → 下一轮聚合周期内卡片自动出现; + - 服务下线/故障 → 卡片自动移除(其余服务卡片不受影响); + - 卡片内容更新 → 前端下一轮刷新自动生效。 +- **范围约束**:v1 只做 `link`(跳转卡片)一种类型 + 协议预留扩展字段;`embed`(内嵌 iframe)与 `data`(动态数据卡片)列为 v2 扩展(§12),本计划不实现。 + +## 2. 现状分析 + +| 事实 | 位置 | 影响 | +|------|------|------| +| fsys 为 C++20 单文件服务(1309 行),路由模式 `server.get/post(path, handler)` | `fsys/src/fsys.cpp` | 新增 `/api/cards` 只需注册一个 handler + 一个后台聚合线程 | +| 已有服务端到服务端调用先例:`_handle_dnfs_proxy` 用 `HttpClient::post_stream_sync` 调 dnfs2:8006 | `fsys.cpp:1222-1268` | 聚合器拉取上游卡片复用同一 HttpClient 模式 | +| 已有 SSRF 防护工具:`validate_url_ssrf` / `is_private_host` / `curl_write_cb` 大小上限 | `fsys.cpp:401-461, 1062-1098` | 聚合器安全校验可复用 | +| 前端无框架(原生 HTML/CSS/JS),首页有现成卡片网格样式 `.feature-card` | `fsys/public/index.html:97-138`、`css/index.css` | 新卡片区复用视觉风格;`js/index.js` 为原生 fetch 模式 | +| 页面安全头 CSP:`connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'` | `fsys.cpp:498-501` | 浏览器只能同源 fetch `/api/cards`(正合设计);iframe 嵌入需同源地址 | +| 内部拓扑:浏览器 → waf:443 → ca:8443 → gateway:8445 → fsys:9200;fsys 可直接访问 127.0.0.1 内部服务 | `core/configs/gateway.ini` | 聚合发生在 fsys 服务端(内部直连,无 CORS 问题);浏览器只访问 fsys 同源端点 | +| dnfs2 为 C++20 HTTP 服务(libhttp),接口与 fsys 同风格 | `dnfs2/` | 作为首个自带卡片的试点服务最合适 | + +## 3. 总体设计 + +``` +┌──────────┐ GET /cards ┌──────────────┐ GET /api/cards ┌────────────┐ +│ dnfs2 │ ───────────────▶ │ fsys │ ◀──────────────── │ 浏览器 │ +│ (:8006) │ 卡片清单 JSON │ CardRegistry │ 合并后卡片 JSON │ (首页) │ +└──────────┘ │ 轮询线程(30s)│ └────────────┘ +┌──────────┐ GET /cards │ 校验+缓存 │ +│ ai_review│ ───────────────▶│ │ js/cards.js 动态渲染 +│ (:8007) │ └──────────────┘ +└──────────┘ ▲ + --card-sources 白名单配置 +``` + +要点: + +1. **服务自带**:每个参与服务实现 `GET /cards`,返回自己的卡片清单(§4)。卡片声明在服务侧,与服务共存亡——这就是"热插拔"的载体。 +2. **fsys 聚合**:fsys 新增 `CardRegistry`(内存缓存 + 后台轮询线程),从 `--card-sources` 白名单中的 URL 定时拉取,逐源校验后合并;对外提供 `GET /api/cards`。 +3. **前端动态渲染**:首页新增"服务入口"卡片区,`js/cards.js` 拉取 `/api/cards` 渲染;定时刷新实现"无感热更新"。 +4. **安全边界**:上游 URL 仅允许配置白名单(内部 127.0.0.1);响应大小/超时/类型强校验;卡片字段白名单校验;前端 DOM API 渲染防 XSS。 + +## 4. 卡片清单协议(cards.json v1) + +### 4.1 服务端点 + +每个参与服务提供: + +``` +GET /cards +``` + +- 无需认证(内部服务,由 fsys 服务端直连;与 `/health` 同级)。 +- 成功返回 `200 application/json`,体为 4.2 节格式。 +- 服务不可用时返回非 2xx 或超时——由 fsys 聚合器视为"该源下线"(§6.4)。 + +### 4.2 响应格式 + +```json +{ + "service": "dnfs2", + "version": 1, + "cards": [ + { + "id": "dnfs-tree", + "title": "树状笔记", + "description": "以树状结构组织知识的笔记社区", + "icon": "/__icons/editor.html.ico", + "url": "/dnfs.html", + "type": "link", + "badge": "", + "order": 0, + "theme": "auto" + } + ] +} +``` + +### 4.3 字段规范 + +| 字段 | 必填 | 约束 | +|------|------|------| +| `service` | 是 | `[a-z0-9-_]{1,32}`,服务标识 | +| `version` | 是 | 固定为 `1`(协议版本) | +| `cards[]` | 是 | 数组,单源上限 16 张 | +| `cards[].id` | 是 | `[a-z0-9-_]{1,64}`,源内唯一 | +| `cards[].title` | 是 | ≤ 64 字符 | +| `cards[].description` | 否 | ≤ 200 字符 | +| `cards[].icon` | 否 | 同源相对路径(`/` 开头,≤ 256 字符)或 `data:image/...` base64(≤ 8KB),不允许 http(s) URL(防第三方跟踪/SSRF 面) | +| `cards[].url` | 是 | **同源相对路径**(`/` 开头)或 `https://` 公网 URL;不允许 `http://`、不允许私有 IP(浏览器侧直连,防中间人 + 内部端口探测) | +| `cards[].type` | 是 | 枚举 `link`(v1);`embed`/`data` 预留(v2,§12) | +| `cards[].badge` | 否 | ≤ 16 字符,角标文本(如"在线"/"beta") | +| `cards[].order` | 否 | 整数,升序排序,缺省 0 | +| `cards[].theme` | 否 | `auto`(缺省)/`light`/`dark`,前端可依此微调卡片底色 | + +> **为什么卡片 URL 只能是相对路径或 https 公网地址**:卡片最终由浏览器点击跳转。浏览器页面来自 `https://:443`(waf 链路),无法直连 `127.0.0.1` 内部端口;内部页面入口(如 dnfs2)已有 fsys/gateway 对外代理路径,卡片直接指向对外可达地址即可。 + +## 5. fsys 聚合器设计 + +### 5.1 新增配置(沿用 CLI > 环境变量 > 默认值) + +| 配置项 | CLI | 环境变量 | 默认 | +|--------|-----|----------|------| +| 卡片源白名单 | `--card-sources`(分号分隔 URL,如 `http://127.0.0.1:8006/cards`) | `CARD_SOURCES` | 空(关闭聚合,`/api/cards` 返回空列表) | +| 轮询间隔 | `--card-refresh`(秒) | `CARD_REFRESH` | 30 | +| 单源超时 | 复用 `--db-timeout` 风格?——新增 `--card-timeout`(秒) | `CARD_TIMEOUT` | 3 | +| 单源响应上限 | 常量 | — | 256 KB(`kCardMaxBytes`) | + +配置校验:`--card-sources` 中的每个 URL 必须通过 `validate_url_ssrf` 校验(仅 http(s)、非私有 IP——此处语义为"仅允许本机内部源",与 favicon 代理的语义一致但方向相反:**白名单模式**,配置里写了什么才拉什么,且解析后仍须落在 127.0.0.1/::1)。 + +`core/services.yaml` 的 fsys 条目同步追加 args(示例): + +```yaml +- "--card-sources" +- "http://127.0.0.1:8006/cards" +- "--card-refresh" +- "30" +``` + +### 5.2 CardRegistry(内存缓存 + 后台线程) + +新增 `class CardRegistry`,结构: + +``` +struct SourceState { + std::string url; // 配置的源 URL + std::string service; // 最近一次成功响应的 service 字段 + std::vector cards; // 最近一次成功拉取的卡片(已校验) + std::string error; // 最近一次失败原因(空=正常) + std::chrono::steady_clock::time_point fetched_at; + int consecutive_failures; // 连续失败计数 +}; + +class CardRegistry { + void start(); // 后台线程:立即拉一轮,之后按 refresh 间隔循环 + void stop(); + json::object snapshot(); // 合并所有源的 /api/cards 响应(带锁) +private: + void poll_once(); // 遍历源,逐一拉取+校验 + std::optional fetch_source(const SourceState&); // 单源拉取 + std::mutex _mutex; + std::map _sources; +}; +``` + +- 后台线程用 `std::condition_variable` + 超时等待实现定时间隔(对齐现有 `stop_watcher` 的停止模式:`stop()` 置标志 + notify,线程退出)。 +- 聚合逻辑与 fsys 主体解耦,`_handle_cards` 只调 `snapshot()`,不阻塞请求线程。 +- **首次拉取**:`start()` 时立即执行一轮(不等第一个间隔),保证部署后首页马上有卡片。 + +### 5.3 单源拉取与校验(poll_once 内逐源执行) + +1. **拉取**:`HttpClient` GET 源 URL,超时 `--card-timeout`,响应体上限 256KB(沿用 `curl_write_cb` 式上限中断或 `set_max_response_size`)。 +2. **类型校验**:响应码必须 200,`Content-Type` 必须 `application/json`(或 `application/json; charset=...`)。 +3. **结构校验**(用 boost::json 解析,任何一项失败 → 该源本轮视为失败): + - `service` 匹配 `[a-z0-9-_]{1,32}`,且须与源配置的期望服务名一致(若配置形如 `dnfs2=http://...`,见 5.4); + - `version == 1`; + - `cards` 为数组且 ≤ 16; + - 逐卡片执行 §4.3 字段校验(id 正则、长度、url 仅相对路径或 https 公网、icon 仅相对路径或 data:、type ∈ {link})。 +4. **更新状态**:成功 → 替换 `cards`,清空 error,`consecutive_failures = 0`;失败 → 保留上一轮成功缓存(**缓存降级**),`error` 记录原因,`consecutive_failures++`。 +5. **下线判定**(§6.4):`consecutive_failures >= 3`(即连续失败约 90 秒)→ 该源标记 `down`,前端不再显示其卡片;成功恢复后立即重新显示。 + +### 5.4 源白名单配置格式 + +两种写法(推荐前者): + +``` +--card-sources "http://127.0.0.1:8006/cards" +--card-sources "dnfs2=http://127.0.0.1:8006/cards" +``` + +带 `name=` 前缀时强制校验响应 `service` 字段一致;不带前缀时以响应中的 `service` 字段为准。两者都不允许指向非 127.0.0.1/::1 的地址(解析后校验,防配置错误打到外网)。 + +### 5.5 GET /api/cards 响应 + +```json +{ + "updated_at": "2026-08-19T10:00:00Z", + "sources": [ + { "service": "dnfs2", "status": "ok", "cards": 1, "error": null }, + { "service": "ai_review", "status": "down", "cards": 0, "error": "timeout" } + ], + "cards": [ + { "id": "dnfs-tree", "service": "dnfs2", "title": "树状笔记", + "description": "...", "icon": "/__icons/editor.html.ico", + "url": "/dnfs.html", "type": "link", "badge": "", "order": 0, "theme": "auto" } + ] +} +``` + +- `cards`:所有 `status=ok` 的源的卡片合并,按 `order` 升序(同 order 按 service+id 字典序)。 +- `updated_at`:本轮聚合完成时间(UTC ISO 8601)。 +- 该端点复用 `make_secured_response`(自动带 CSP 等安全头);CORS 按现有 `_apply_cors` 处理。 + +## 6. 热插拔语义(行为契约) + +| 场景 | 行为 | 时效 | +|------|------|------| +| 新服务上线(配置了源,服务启动) | 聚合器下一轮拉取成功 → `/api/cards` 出现其卡片 → 前端下一轮刷新渲染 | ≤ refresh + 前端刷新间隔 | +| 服务正常下线(进程退出) | 拉取失败 → 缓存降级 → 连续失败 3 轮后 `status=down`,卡片从前端消失 | ~90s | +| 服务短暂抖动(重启) | 失败期间保留旧卡片(缓存降级),恢复后自动更新 | 抖动期间卡片仍可见 | +| 卡片内容更新(服务改了清单) | 下一轮拉取成功即替换 | ≤ 30s | +| 新卡片加入 / 旧卡片移除 | 随清单整体替换(源内以 id 为准整体覆盖) | ≤ 30s | +| 源配置变更(新增/删除源) | 需重启 fsys(配置静态,v1 不做运行时重载;后续可复用 confs 热更新模式扩展) | 重启时 | + +> 前端定时刷新(§7.3)默认 60s;聚合 refresh 默认 30s。端到端可见性 ≈ 90s 内。 + +## 7. 前端改动 + +### 7.1 index.html + +在 `#features` 区块之后、"关于本站"之前插入新区块: + +```html +
+
+

服务入口

+

本站提供的服务

+
+
+
+``` + +- 容器初始为空(`cards.js` 负责填充),无 JS / 聚合关闭时该区块高度塌陷或显示占位文案,不影响首屏。 +- 页脚引入 ``。 + +### 7.2 css/index.css(或新增 css/cards.css) + +- `.cards-grid`:`display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap`,复用 `.feature-card` 的视觉语言(圆角、hover 抬升、深色模式变量)。 +- `.svc-card`:链接卡片(`` 整卡可点),含 icon 位、标题、描述、badge 角标。 +- 空态样式 `.cards-empty`("暂无可用的服务卡片")。 + +### 7.3 js/cards.js(新增,原生 JS 无依赖) + +```js +(function () { + 'use strict'; + const REFRESH_MS = 60 * 1000; // 与聚合 refresh 协调,见 §6 + const grid = document.getElementById('cards-grid'); + + function render(data) { /* 见下 */ } + async function load() { + try { + const res = await fetch('/api/cards'); + if (!res.ok) throw new Error('HTTP ' + res.status); + render(await res.json()); + } catch (e) { + console.warn('cards: 拉取失败', e.message); + // 保留上一帧渲染结果,不闪烁;下一轮重试 + } + } + load(); + setInterval(load, REFRESH_MS); +})(); +``` + +- **渲染安全(XSS 防线)**:一律用 `document.createElement` + `textContent` 赋值;禁止 `innerHTML` 拼接任何来自 `/api/cards` 的字段(title/description/badge 均视为不可信)。`icon` 仅允许相对路径(拼 `src` 前校验 `/` 开头)或 `data:image/` 前缀(且整体长度 ≤ 8KB)。`url` 仅允许 `/` 开头相对路径或 `https://` 前缀(与 §4.3 一致,前端二次防御)。 +- `target` 处理:相对路径同窗口跳转;https 外链 `target="_blank" rel="noopener noreferrer"`。 +- `source.status === 'down'` 的源不渲染其卡片;`cards` 为空时显示空态文案。 +- 深色模式:`theme` 字段为 `dark/light` 时给卡片追加类名,由 CSS 微调。 + +### 7.4 CSP 兼容性核查 + +- `connect-src 'self'` → `/api/cards` 同源 fetch,通过。 +- `img-src 'self' data: blob:` → icon 相对路径与 data: 图,通过。 +- iframe(v2 `embed`)→ `frame-ancestors 'self'` 限制的是"本站被嵌入",不限制本站嵌入同源页面,届时 `embed_url` 须为同源地址;v1 无需改动 CSP。 + +## 8. 试点服务:dnfs2 + +作为首个自带卡片的服务,验证协议端到端: + +- 在 dnfs2(C++20,libhttp)新增路由 `GET /cards`,返回静态卡片清单: + +```json +{ + "service": "dnfs2", + "version": 1, + "cards": [ + { "id": "dnfs-tree", "title": "树状笔记", + "description": "以树状结构组织知识的笔记社区,支持 Markdown 与多媒体分享", + "icon": "/__icons/editor.html.ico", "url": "/dnfs.html", + "type": "link", "badge": "在线", "order": 0, "theme": "auto" } + ] +} +``` + +- `url: /dnfs.html` 指向 fsys 托管的笔记页(现有对外路径),无需新增对外路由。 +- 后续其他服务(ai_review、aibox 等)按同一协议各加一个 `GET /cards` 即可,fsys 侧零改动。 + +## 9. 安全设计(威胁模型) + +| 威胁 | 缓解 | +|------|------| +| 上游源被恶意控制 → 卡片携带恶意内容(XSS) | ① fsys 聚合端字段级校验(id/title/url/icon 白名单正则 + 长度上限);② 前端 textContent 渲染 + url/icon 前缀二次校验;③ 卡片内容全部视为不可信数据 | +| 恶意源返回超大响应 → 内存 DoS | 单源响应上限 256KB + 超时 3s + 卡片数上限 16/源 | +| 配置被篡改指向外网/内网探测 | `--card-sources` 白名单 + 解析后必须 127.0.0.1/::1(fail-closed 拒绝启动或跳过该源并告警日志);文件权限收紧(services.yaml 属主 root) | +| 上游服务伪造 service 名 | 带 `name=` 前缀的源强制校验响应 `service` 一致 | +| 聚合器错误导致 fsys 故障 | 单源失败只降级该源,绝不抛异常出请求线程;`snapshot()` 全程锁保护;上游超时由 HttpClient 控制 | +| 卡片 URL 指向内部端口(浏览器 SSRF 面) | 聚合端仅放行 `/` 相对路径与 `https://` 公网;前端二次校验同规则 | +| 前端轮询放大 | 60s 间隔 + 单请求,可忽略 | + +## 10. 测试策略 + +`tests/blackbox/test_static.py` 追加(纯 Python 标准库,沿用现有 helpers): + +1. **聚合正常路径**:`GET fsys:/api/cards` → 200;`sources` 含 `dnfs2` 且 `status=ok`;`cards` 含 `id=dnfs-tree`,字段完整。 +2. **协议字段校验**:构造合法 `GET /cards` 假源验证校验逻辑(可用独立测试进程起一个临时 HTTP 服务,指向 127.0.0.1 随机端口,fsys 配置加该源)——校验非法 id、超长 title、非法 url 被剔除,源标记 error。 +3. **源下线**:假源停止服务 → 聚合轮询后该源 `status=down`,其卡片不出现;合法源卡片不受影响。 +4. **前端静态检查**:`js/cards.js` 存在且 index.html 正确引入(沿用现有静态断言风格)。 +5. 回归:现有 test_static.py 全部用例(根路径、文件浏览、Range 视频流)不回归。 + +## 11. 实施步骤与文件清单 + +| # | 步骤 | 文件 | +|---|------|------| +| 1 | 协议常量与配置项:新增 `kCardMaxBytes`、`kCardMaxPerSource`、`kCardDownAfter` 等常量;`Config` 增加 `card_sources` / `card_refresh` / `card_timeout`,`load_config` 增加 CLI/env/校验 | `fsys/src/fsys.cpp` | +| 2 | `CardRegistry`:SourceState/Card 结构、`start/stop/poll_once/fetch_source/snapshot`、后台线程 | `fsys/src/fsys.cpp` | +| 3 | 路由注册:`server.get("/api/cards", ...)` + `_handle_cards` handler | `fsys/src/fsys.cpp` | +| 4 | 服务实例化与生命周期:`FsysServer` 构造时建 registry,`run()` 启动、退出时 stop | `fsys/src/fsys.cpp` | +| 5 | 前端容器 + 样式 + 渲染脚本 | `fsys/public/index.html`、`fsys/public/css/index.css`、`fsys/public/js/cards.js` | +| 6 | 试点服务:dnfs2 新增 `GET /cards` 路由(静态清单) | `dnfs2/src/`(对应 main/服务源文件) | +| 7 | 部署配置:fsys 条目追加 `--card-sources` 等 args | `core/services.yaml` | +| 8 | 黑盒测试 | `tests/blackbox/test_static.py`(或新增 `test_cards.py`) | +| 9 | 文档同步:根 README(服务说明表 + 端口表无变化、fsys 节补聚合说明)、本计划状态改为已实施 | `README.md` | + +依赖顺序:1 → 2 → 3/4 → 5(前端可并行)→ 6 → 7 → 8 → 9。构建验证:`cd fsys && bash _set.sh`(make 编译通过)、`cd dnfs2 && bash _set.sh`。 + +## 12. 后续扩展(v2,不在本期) + +- **`embed` 卡片**:iframe 内嵌服务自带子页面。需 fsys 提供子路径代理(如 `/card_embed//`,内部转发到源服务),并给 iframe 加 `sandbox` 属性;CSP 需评估 `frame-src`。 +- **`data` 卡片**:卡片带 `data_url`,前端定时拉 JSON 渲染(如"服务状态"卡片)。数据拉取建议经 fsys 代理(浏览器不能直连内部端口),需新增代理端点并做响应校验。 +- **源配置热重载**:`--card-sources` 支持运行时刷新(复用 confs mtime 重载模式)。 +- **卡片注册中心**:未来若服务数量增多,可考虑由 core 统一登记 `GET /cards` 地址,fsys 从 core 发现,免去手工维护白名单。 + +## 13. 风险与权衡 + +- **聚合器是新的常驻后台线程**:逻辑简单(sleep/notify 轮询),崩溃面小;单源故障隔离 + 缓存降级保证主请求路径不受影响。 +- **卡片可见性延迟 ~90s**:热插拔非"秒级",对社区首页场景可接受;如需秒级可后续加 WebSocket 推送(v2)。 +- **信任模型**:fsys 信任 `--card-sources` 白名单内的服务所返回的卡片内容(经校验后)。白名单配置属于部署安全边界,与 services.yaml 同级保护(root:root 0640,见根 README 部署安全约定)。 +- **不做**:v1 不含卡片管理 UI、不含用户自定义卡片、不含 embed/data 类型——保持最小可用闭环。