Files
blackbird/docs/reports/fsys_cards_plugin_dev_doc.md
T

21 KiB
Raw Blame History

热插拔嵌入式前端页面(服务自带卡片)开发计划

  • 日期: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 响应格式

{
  "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://<host>: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(示例):

- "--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<Card> 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<SourceState> fetch_source(const SourceState&);  // 单源拉取
    std::mutex _mutex;
    std::map<std::string, SourceState> _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 响应

{
  "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 区块之后、"关于本站"之前插入新区块:

<section id="service-cards" class="section cards-section">
  <div class="wrap">
    <p class="section-eyebrow">服务入口</p>
    <h2 class="section-title">本站提供的服务</h2>
    <div id="cards-grid" class="cards-grid" aria-live="polite"></div>
  </div>
</section>
  • 容器初始为空(cards.js 负责填充),无 JS / 聚合关闭时该区块高度塌陷或显示占位文案,不影响首屏。
  • 页脚引入 <script src="js/cards.js"></script>。

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:链接卡片(<a> 整卡可点),含 icon 位、标题、描述、badge 角标。
  • 空态样式 .cards-empty("暂无可用的服务卡片")。

7.3 js/cards.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,返回静态卡片清单:
{
  "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/<service>/<path>,内部转发到源服务),并给 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 类型——保持最小可用闭环。