chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
# Fleet:身份、注册与连接
## 1. 概览
```text
fleet-enroll(一次性运行)
│ ① 用加入令牌注册,生成身份
▼
┌────────── 节点 ──────────┐ ┌──────────── 服务端 ────────────┐
│ Fleet.Hosting │ ② wss 连接 │ Fleet.Server.Link │
│ └─ Fleet.Link ─────────────── 签名认证 ────>│ 连接注册表 · 在线事件 │
│ ├─ Rpc 路由 │<── state.sync ──│ 期望状态提供者(产品实现) │
│ ├─ 期望状态调和 │── outbox.push ─>│ 收件箱(产品实现,持久化后 ACK) │
│ └─ 可靠发件箱 │<── outbox.ack ──│ │
│ │ │ Fleet.Server.Enrollment │
│ Fleet.Link(点对点)──────── TLS + 签名 ────> 另一个节点(例如 Probe → Agent) │
└──────────────────────────┘ └────────────────────────────────┘
```
## 2. 身份(Fleet.Identity)
| 对象 | 算法 | 保存位置 |
| --- | --- | --- |
| 节点身份密钥 | ECDSA P-256(BCL 自带;Ed25519 不在 BCL 中) | `<identity_dir>/node.key`(0600);服务端只保存公钥 |
| 点对点 TLS 证书 | 自签名,ECDSA P-256 | `<identity_dir>/tls.pfx`;对端按 SPKI SHA-256 指纹校验 |
| 签名串 | `"fleet|v1|<purpose>|" + 各字段` | 带用途前缀,防止同一个签名在不同场景下被复用 |
用途前缀的取值:`auth`(连接服务端)、`peer`(点对点)、`enroll`(注册时的持有证明)。产品可以追加自己的用途(例如 `myapp.xxx`),但不能占用 `fleet` 保留的用途。
## 3. 注册(Fleet.Enrollment)
```text
管理员 ── 创建加入令牌 {labels, max_uses, expires_at, auto_approve, product_meta?} ──> 服务端
<── 令牌明文(只返回一次)+ 安装命令
节点:fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/myapp
├─ 生成身份密钥和 TLS 证书
├─ POST <enroll 端点>
│ { join_token, name, pubkey, tls_spki, proof = 签名("enroll", join_token 哈希 + pubkey),
│ node_version, meta(产品自定义的 JSON,经产品的 JsonTypeInfo 校验)}
│ 服务端:校验令牌 → uses+1 → 创建节点(active 或 pending)→ 触发 OnEnrolled 事件(产品挂钩)
└─ 写入 <identity_dir>/node.json { node_id, server, … },然后退出
```
- **名称冲突**:同名节点已存在时,新注册进入 `pending`,由管理员选择“接管”(旧记录换成新公钥,历史数据保留)或“作为新节点”。
- **节点状态**:`pending`(能连接,但不下发状态、不接收上报)、`active`、`disabled`(拒绝连接)。
- 注册逻辑只存在于 `fleet-enroll` 中,**常驻的业务进程不包含注册代码,也不使用 HttpClient**。
- 产品可以复用 `fleet-enroll` 本身,也可以把 `Fleet.Enrollment.Client` 嵌入自己的安装工具中。
## 4. RPC(Fleet.Rpc)
- 信封采用 JSON-RPC 2.0;方法名为 `领域.动作`。`fleet.*` 保留给框架,产品使用自己的前缀,例如 `monitor.*`。
- 二进制帧:`u8 channel | 正文`。channel `0` 到 `15` 保留给框架,产品从 `16` 开始注册自己的二进制通道。
- 错误:`error.data.code` 为 snake_case 字符串,框架的错误码以 `fleet_` 开头,产品可以扩展。
- **与传输方式无关**:同一套路由既可以跑在 wss 上,也可以跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
框架保留的方法:
| 方法 | 方向 | 说明 |
| --- | --- | --- |
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
| `fleet.hello` | 节点→服务端 | 节点版本、能力列表(框架 + 产品)、本地策略摘要、当前时间 → 返回时钟偏差 |
| `fleet.heartbeat` | 节点→服务端 | 每 10 s 一次;产品可以附加进程级计数 |
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
## 5. 连接生命周期(Fleet.Link)
```text
connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 → 正常运行
▲ │
└──── 指数退避重连(1 s → 60 s,带随机抖动;401 或 revoked 时停止重试)◀─ 断开
```
- 能力协商:双方交换 capabilities;调用对方不支持的方法时,在本地直接返回 `fleet_unsupported`,不会发出请求。
- 节点本地策略:`allow_methods`(白名单,默认只包含本地已注册的方法)、`max_frame_bytes`、`max_inflight`。
## 6. 可靠发件箱(Outbox)
```text
产品 → outbox.EnqueueAsync(msg) → 有界队列(按字节和条数限制;满时按策略:DropOldest 或 Reject)
→ 发送(二进制通道或 JSON)→ 服务端收件箱处理并持久化 → fleet.outbox.ack → 从队列移除
```
- 投递语义为**至少一次**:每条消息都带有 `msg_id`(UUIDv7),服务端按此去重。
- 在途数据按字节限制(默认 4 MiB),保证服务端慢时不会把内存撑爆。
- 丢弃消息时会累计缺口计数,通过 `OnGap` 回调通知产品,由产品决定如何上报。
- 默认只保存在内存中,不落盘。产品如果需要持久化,可以自行实现 `IOutboxStore`(Monitor 不使用)。
## 7. 期望状态同步(State Sync)
```text
服务端:IStateProvider<TDoc>.GetAsync(node) → {version, doc}
发生变化时调用 NotifyChanged(node) → 推送 fleet.state.sync
节点: IStateReconciler<TDoc>.ApplyAsync(doc) → {applied_version, rejected[]}
重连后,服务端总会重新推送当前版本
```
- 下发的是**完整快照**而不是命令,所以天然幂等,断线重连后也能自动收敛。
- `rejected[]` 为结构化原因,例如 `unsupported`、`budget_exceeded`、`policy_denied`,服务端可以把它展示在面板上。
## 8. 节点间点对点会话
```text
节点 A(发起方,例如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,例如 Agent)
<── fleet.challenge ──
── fleet.auth(peer 用途,节点 A 的私钥签名)──>
节点 B 用服务端下发的对端公钥列表验签(通常经 state.sync 下发)
之后使用同一套 Fleet.Rpc:方法路由、二进制通道、发件箱都可以复用
```
- 对端的公钥和允许连接的节点列表,由产品通过期望状态下发给监听方。Fleet 只负责认证和会话,拓扑由产品决定。
- 数据面的 UDP、原始 TCP 流等通道由产品自己实现。Fleet 只提供会话密钥派生(`session_key`)和首帧 MAC 的辅助函数。
## 9. 宿主(Fleet.Hosting)
- `ModuleHost`:按依赖顺序启动模块,按相反顺序停止;隔离故障,重启时退避。
- `NodeConfig<T>`:源生成 JSON 读取,支持环境变量覆盖,启动时校验。
- `ConfigStore`:业务进程唯一允许写文件的地方,只能写配置目录。
- `ServiceShell`:处理 SIGTERM 和 Ctrl+C,支持 systemd `sd_notify`、OpenWrt procd(前台运行,由 procd 负责重启)和 Windows 服务。
- `HealthBeacon`:进程完成启动并与服务端握手后,写出 `{app, version, pid, ok_at}` 到本地信标文件或命名管道,供 `fleet-updater` 确认新版本是否健康。
## 10. 服务端存储抽象
```csharp
public interface IFleetStore
{
ValueTask<JoinToken?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenHash, CancellationToken ct);
ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct);
ValueTask<NodeRecord?> GetNodeAsync(NodeId id, CancellationToken ct);
ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct);
// 令牌、审批、接管、禁用等
}
```
产品可以使用 `Fleet.Server.Sqlite`,也可以把这些表放进自己的数据库中。Monitor 的做法是把它们放进自己的 `monitor.db`,并由同一个单写入者负责写入。
+104
View File
@@ -0,0 +1,104 @@
# Fleet:通用节点框架
> 名称暂定。Fleet 与业务无关,Monitor 是它的第一个使用者。
## 1. 定位
凡是“很多节点连到一个面板”的产品,都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置、怎么更新自己。Fleet 把这些做成**通用库和工具**,产品只需要写业务。
```text
┌──────────────────────────── 产品层(各产品自己写) ─────────────────────────────┐
│ Monitor: Protocol(Kind/任务) · Measurement(积木) · Agent · Probe · Backend │
│ 其他产品: YourApp.Node · YourApp.Server │
└──────────────────────────────────┬─────────────────────────────────────────────┘
│ 只依赖 Fleet;Fleet 不认识任何产品
┌───────────────────────────────── Fleet ───────────────────────────────────────┐
│ 节点侧(只依赖 BCL,支持 AOT) 服务端侧(ASP.NET Core) │
│ Fleet.Core Result/退避/有界队列 Fleet.Server.Enrollment 注册与审批 │
│ Fleet.Identity 密钥/签名/证书/存储 Fleet.Server.Link wss 网关/在线表 │
│ Fleet.Rpc JSON-RPC/路由/二进制帧 Fleet.Server.Releases 通道与灰度(可选)│
│ Fleet.Link 会话/认证/重连/能力/ Fleet.Server.Sqlite 存储实现(可选)│
│ 发件箱/状态同步/点对点 IFleetStore 存储抽象 │
│ Fleet.Hosting 模块宿主/配置/日志/服务外壳/健康信标 │
│ Fleet.Update 清单/验签/安装/切换/回滚 │
│ 工具:fleet-enroll(注册) fleet-updater(更新) fleet-release(CI 签名) │
└──────────────────────────────────────────────────────────────────────────────┘
```
## 2. 设计原则
1. **与业务无关**:Fleet 中不出现任何产品概念。产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。`scripts/check-deps` 强制规定 `backend/fleet/**` 不能引用 `Monitor.*`。
2. **安全默认值**:
- Fleet **永远不提供** exec、shell、文件读写、远程下发代码这类方法;
- 节点只响应本地注册过的方法,本地策略 `allow_methods` 还可以进一步收窄;
- 更新必须通过离线密钥签名验证,面板即使被攻破也推不出恶意二进制。
3. **权限分离**:注册(`fleet-enroll`,一次性运行)、常驻业务进程、更新(`fleet-updater`)是三个独立的进程,使用不同的系统用户,各自只拥有最小权限。
4. **NativeAOT 优先**:节点侧只依赖 BCL;泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 实现序列化,不使用反射。
5. **只抽取真正用到的**:每项能力都必须同时被 Monitor 和 `samples/EchoNode` 使用,防止过度设计。
## 3. 组件
| 组件 | 侧 | 职责 | 详细 |
| --- | --- | --- | --- |
| `Fleet.Core` | 两侧 | `Result<T>`、错误码基类、退避、有界队列、ULID/UUIDv7 | — |
| `Fleet.Identity` | 两侧 | ECDSA P-256 身份密钥、自签名证书、SPKI 指纹、带用途前缀的签名串、`KeyStore`(文件权限 0600) | [link.md](link.md) §2 |
| `Fleet.Rpc` | 两侧 | JSON-RPC 2.0 信封、方法路由、请求与通知、二进制帧、错误映射;与传输方式无关 | [link.md](link.md) §4 |
| `Fleet.Link` | 节点 | 连接服务端(wss)或其他节点(TLS);认证、心跳、重连、能力协商、可靠发件箱、期望状态同步 | [link.md](link.md) |
| `Fleet.Enrollment.Client` | 节点 | 加入令牌注册流程(供 `fleet-enroll` 使用) | [link.md](link.md) §3 |
| `Fleet.Hosting` | 节点 | 模块宿主、配置(源生成 JSON)、logfmt 日志、服务外壳(systemd/procd/Windows)、健康信标 | [link.md](link.md) §8 |
| `Fleet.Update` | 节点 | 清单格式、验签、下载校验、多版本目录与原子切换、服务适配器、健康确认、回滚 | [update.md](update.md) |
| `Fleet.Server.Enrollment` | 服务端 | 加入令牌、注册端点、审批与接管 | [link.md](link.md) §3 |
| `Fleet.Server.Link` | 服务端 | wss 端点、签名认证、连接注册表、在线事件、状态下发、收件箱 ACK | [link.md](link.md) |
| `Fleet.Server.Releases` | 服务端(可选) | 托管清单、管理通道、灰度发布、汇总各节点版本 | [update.md](update.md) §6 |
| `Fleet.Server.Sqlite` | 服务端(可选) | `IFleetStore` 的 SQLite 实现(节点、令牌、公钥、状态版本) | — |
| `fleet-enroll` | 工具 | 节点注册:生成密钥、调用注册端点、写入配置 | [link.md](link.md) §3 |
| `fleet-updater` | 工具 | 节点更新守护进程,可以同时管理多个产品 | [update.md](update.md) |
| `fleet-release` | 工具 | 在 CI 或离线环境中生成并签名发布清单 | [update.md](update.md) §3 |
## 4. 目录结构
```text
fleet/ 以后可以原样拆成独立仓库并发布 NuGet 包
├─ Directory.Build.props 导入仓库根目录的 props
├─ src/
│ ├─ Fleet.Core/ Fleet.Identity/ Fleet.Rpc/ Fleet.Link/ Fleet.Hosting/ Fleet.Update/
│ ├─ Fleet.Enrollment.Client/
│ └─ Fleet.Server.Enrollment/ Fleet.Server.Link/ Fleet.Server.Releases/ Fleet.Server.Sqlite/
├─ tools/ fleet-enroll/ fleet-updater/ fleet-release/
├─ samples/ EchoNode/ EchoServer/ 最小示例:不依赖 Monitor 的第二个使用者
├─ tests/
└─ docs → 仓库 docs/fleet/
```
## 5. 演进策略
- **现阶段**(已确认,暂不独立):放在 monitor 仓库的 `backend/fleet/` 目录中,通过项目引用使用,一处修改两边同时编译。
- **拆分条件**:公开 API 连续两个里程碑没有破坏性变更,并且 `samples` 能覆盖全部能力。满足后拆成独立仓库,按 semver 发布 NuGet 包,Monitor 改为包引用。
- **公开 API 审查**:`backend/fleet/src/*` 的 public 类型使用 `PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt` 跟踪(使用 `Microsoft.CodeAnalysis.PublicApiAnalyzers`,只在构建期生效),任何变更都会在 PR 中显示出来。
## 6. 产品接入一览
**节点侧**:
```csharp
var identity = NodeIdentity.Load(config.IdentityDir); // 由 fleet-enroll 生成
var link = FleetLink.ToServer(config.Server, identity, MyRpcContext.Default)
.WithCapabilities(["myapp.v1"])
.WithState<MyDesired>(MyRpcContext.Default.MyDesired, reconciler) // 期望状态同步(可选)
.WithOutbox<MyReport>(MyReportCodec.Instance, maxBytes: 16 << 20) // 可靠上报(可选)
.On<DoThingParams, DoThingResult>("myapp.do_thing", HandleAsync); // 业务方法
var host = new ModuleHost(log).Add(link).Add(myModules);
await ServiceShell.RunAsync(host, HealthBeacon.From(config)); // 同时提供更新所需的健康确认
```
**服务端**:
```csharp
builder.Services.AddFleetServer(o => o.UseStore<SqliteFleetStore>())
.AddState<MyDesired>(desiredProvider) // 为每个节点计算期望状态
.AddInbox<MyReport>(reportHandler) // 持久化之后再 ACK
.OnNodeEvent(presenceHandler); // 上线、下线、版本变化
app.MapFleetEnrollment("/fleet/enroll");
app.MapFleetLink("/fleet/rpc");
app.MapFleetReleases("/fleet/releases"); // 可选
```
+110
View File
@@ -0,0 +1,110 @@
# Fleet:通用更新(Fleet.Update / fleet-updater)
## 1. 原则
- **与产品无关**:`fleet-updater` 通过本地配置声明它管理哪些应用,一台机器上可以同时管理多个产品。
- **信任来自签名,而不是来源**:只接受由**离线发布密钥**签名的清单。发布源可以是面板、GitHub Releases、对象存储或任意 HTTP 服务。**面板即使被攻破,也推不出恶意二进制。**
- **本地说了算**:更新策略(`auto`、`notify`、`pinned`)写在节点本地的配置中,面板只能“建议”目标版本。
- **权限分离**:业务进程不能改写自己的二进制;更新进程读不到业务身份私钥,也不参与业务。
```text
┌─ 节点 ─────────────────────────────────────────────────────────────────────┐
│ myapp(业务进程) 用户 myapp:只读 /opt/myapp,只能写配置目录 │
│ └─ HealthBeacon ──写──> /run/fleet/myapp.beacon │
│ fleet-updater 用户 fleet-update:只能写 /opt/myapp/versions, │
│ ├─ 读取信标 通过 polkit/sudoers 规则只允许重启 myapp.service │
│ └─ 不读取 node.key │
└────────────────────────────────────────────────────────────────────────────┘
```
## 2. 本地配置(`/etc/fleet/updater.json`)
```json
{
"trust": [
{ "id": "rel-2026a", "alg": "ecdsa-p256", "pubkey": "BASE64..." },
{ "id": "rel-2027a", "alg": "ecdsa-p256", "pubkey": "BASE64..." }
],
"check_interval_s": 3600,
"apps": [
{
"id": "monitor-agent",
"sources": ["https://panel.example.com/fleet/releases", "https://github.com/org/monitor/releases/download"],
"channel": "stable",
"policy": "notify",
"rid": "linux-x64",
"install_dir": "/opt/monitor-agent",
"keep_versions": 2,
"service": { "kind": "systemd", "name": "monitor-agent" },
"health": { "kind": "beacon", "path": "/run/fleet/monitor-agent.beacon", "timeout_s": 60 }
}
]
}
```
- `trust` 可以配置多把公钥,用于轮换;清单中的 `key_id` 用来选择验证哪一把。
- `policy` 取值:`auto`(验签通过即安装)、`notify`(只下载并上报“有可用更新”,等待面板上的人工批准)、`pinned:<version>`(锁定版本)。
- 面板上的批准只对 `notify` 生效,并且**批准的版本同样必须通过签名验证**。
## 3. 发布清单(由 `fleet-release` 在 CI 或离线环境中生成)
```json
{
"schema": 1,
"app": "monitor-agent",
"channel": "stable",
"version": "0.4.2",
"released_at": "2026-10-01T00:00:00Z",
"min_from": "0.3.0",
"artifacts": [
{ "rid": "linux-x64", "url": "monitor-agent-0.4.2-linux-x64.tar.gz", "sha256": "…", "size": 4120334 },
{ "rid": "linux-arm64", "url": "monitor-agent-0.4.2-linux-arm64.tar.gz", "sha256": "…", "size": 3987112 },
{ "rid": "linux-musl-arm64", "url": "monitor-agent-0.4.2-linux-musl-arm64.tar.gz", "sha256": "…", "size": 3990021 },
{ "rid": "win-x64", "url": "monitor-agent-0.4.2-win-x64.zip", "sha256": "…", "size": 4455010 }
],
"notes_url": "https://…/CHANGELOG#0.4.2",
"key_id": "rel-2026a",
"signature": "BASE64(ECDSA(规范化 JSON,不含 signature 字段))"
}
```
- 签名范围是**规范化后的 JSON**:键排序、无空白、不含 `signature` 字段。规范化规则由 `Fleet.Update` 实现,签名端和验签端使用同一份代码。
- 发布源的目录约定:`<source>/<app>/<channel>/latest.json`(指向当前版本)和 `<source>/<app>/<version>/manifest.json`。
- 防回滚:updater 在本地记录每个应用已安装过的最高版本;除非本地配置显式允许降级,否则拒绝安装更低的版本,防止旧的签名清单被重放。
## 4. 更新流程
```text
轮询(间隔加随机抖动)
└─ 拉取 latest.json → 验签 ✗ → 记录并忽略
├─ 版本 ≤ 当前版本,或当前版本 < min_from → 跳过(后者需要人工介入)
├─ policy = notify → 下载并校验 → 上报“可以更新” → 等待批准
└─ policy = auto 或已批准:
下载 → 校验 sha256 和大小 → 解包到 versions/<v>/ → 检查可执行文件
→ 原子切换 current → versions/<v>(Linux 用符号链接,Windows 见 §5)
→ 重启服务 → 等待健康信标(version == v,并且 ok_at 晚于重启时刻)
✓ → 记录成功,按 keep_versions 清理旧版本
✗ 超时 → 切回旧版本 → 重启 → 记录失败,该版本标记为坏版本,不再自动尝试
```
## 5. 平台适配
| 平台 | 切换方式 | 服务管理 |
| --- | --- | --- |
| Linux | `current` 符号链接 + `rename(2)` 原子替换 | systemd:`systemctl restart <name>`,由 polkit 规则限定只能操作被管理的单元 |
| Windows | 服务的 ImagePath 指向 `current\app.exe`;先停服务,再切换目录联结(junction),然后启动 | SCM:updater 的服务账户只被授予这几个服务的启动和停止权限 |
| OpenWrt | 与 Linux 相同的符号链接切换;默认 `keep_versions = 1`,下载前检查闪存剩余空间 | procd:`/etc/init.d/<name> restart`(updater 以 root 运行,这是 OpenWrt 的常见做法;仍然不读取节点身份目录) |
| 其他(以后) | launchd、OpenRC | 通过适配器接口 `IServiceAdapter` 扩展 |
## 6. 面板侧(`Fleet.Server.Releases`,可选)
- 托管清单和制品(也可以只做代理或索引,制品放在 GitHub 或对象存储)。
- **通道与灰度**:每个通道可以指定目标版本,并按标签分批发布,例如先更新 1 台节点,观察 N 分钟没有失败后再更新其余节点。
- **状态汇总**:节点在 `fleet.hello` 中上报 `{app, version}`,updater 上报 `{available, installing, failed, rolled_back}`。面板据此展示版本分布和更新进度。
- updater 与面板通信时,**不使用业务进程的身份**。首版只轮询公开的通道清单,完全不需要身份,状态也由业务进程在 `fleet.hello` 中代为上报。给 updater 单独一对身份密钥是以后的扩展。
## 7. 发布密钥管理
- 私钥只存在于 CI 的密钥服务或离线环境中,**永远不上传到面板**。
- 轮换方式:新旧两把公钥同时写进 `trust` → 用新密钥发布 → 所有节点都更新到信任新密钥的版本后 → 移除旧公钥。
- 密钥泄露时:发布一个用新密钥签名、并从 `trust` 中移除旧公钥的版本;已经被恶意更新的节点需要人工重装。