docs: describe the unified Agent topology and link controls

Align architecture, protocol, API, storage, security and deployment documentation with default mutual measurements and the Agent link board.
This commit is contained in:
justme committed 2026-10-21 11:55:55 +08:00
1 parent 15f0023614
commit c9beeee8e3
16 files changed
+363 -383

No files matched your search

+7 -7
View File
@@ -48,7 +48,7 @@
| 组 | 能力 | 说明 | 信任类 |
| --- | --- | --- | --- |
| 展示 | `public.read` | 看隐藏线路;站点私有时看展示页 | |
| 节点 | `fleet.read` | 看 Agent、Probe、线路、加入代码 | |
| 节点 | `fleet.read` | 看 Agent、线路、加入代码 | |
| | `fleet.write` | 改名、标签、可见性、手填地址、线路开关、断开会话 | |
| | `fleet.enroll` | 生成加入代码 | |
| | `fleet.revoke` | 吊销或删除节点 | 是 |
@@ -75,7 +75,7 @@
| passkey | WebAuthn,只支持 ES256,attestation 一律当 `none`;可以登录,也能给操作员集合签名(节点验证) |
- 两种方式的会话拿同样的能力。passkey 只用在节点自己会验证的事上,见第 5 节。
- 认领向导(没有默认账户):面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent 和 Probe,只能进面板做基础管理,这是架构如此。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认。
- 认领向导(没有默认账户):面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent,只能进面板做基础管理,这是架构如此。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认。
- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。
### 4.1 passkey 登录的实现
@@ -121,7 +121,7 @@ CI 把签名清单 POST 给 `POST /api/release/offer`,面板用发布公钥验
## 7. 归属与审计
- 加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。
- 加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent 详情显示「由 xxx 于 xxx 添加」。
- 审计日志(`GET /api/admin/audit`):所有非 GET 的管理操作和登录自动记录(时间、账户、登录方式、动作、对象、详情)。写不进去只记日志,不让已完成的操作报错。保留 365 天,只追加。
## 8. 入网信任链的实现要点
@@ -136,19 +136,19 @@ CI 把签名清单 POST 给 `POST /api/release/offer`,面板用发布公钥验
## 9. 节点属主与 extension 授权
extension(web ssh、文件、抓包,见 [security.md](security.md))现在都不实现,Agent 和 Probe 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。
extension(web ssh、文件、抓包,见 [security.md](security.md))现在都不实现,Agent 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。
已经实现的部分
- 属主 `controllers` 是一组 passkey 凭证 id,必须是操作员集合里的凭证。由安装命令带来,节点注册时写进 `trust.json`,Backend 之后改不了。
- 每个 Agent 和 Probe 都必须有属主,至少一把。这由节点自己强制:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了。
- 每个 Agent 都必须有属主,至少一把。这由节点自己强制:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了。
- 添加节点时选属主:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`,不给就是操作员集合里的全部凭证;给了就必须非空且都在集合里。还没有信任根或一把属主都没有时,面板提前拒绝创建加入码(400,字段 `controllers`),这只是尽早失败,真正的强制在节点;`MoShiro__RequireOwner=false` 可以关掉这个提前检查(只给没有信任根的老部署)。界面里是必选的「属主(passkey)」多选列表,只是选,不用触碰。
- 安装端也强制:`install.sh`、`install.ps1`、容器入口缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装;`fleet-enroll --genesis` 不带 `--controllers` 也拒绝。
- 逆推出绑定关系:节点握手时把本地固定的属主(passkey 凭证 id)报给面板(hello 的 `owners`),面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上,显示在「系统 → 版本」的「属主」列(对不上任何已注册 passkey 的显示「未知凭证」)。上报只是自述,真正的授权由节点自己校验 passkey 签名。
预留的设计(还没做)
- 谁来强制:节点本地。web ssh 这类会话要不要加密、要不要验证,由 Agent 和 Probe 自己要求。让 Backend 决定,被攻破的 Backend 一句「这次不用加密」就绕过去了。节点本地强制:没有操作员的现场证明不启动会话;会话密钥只和证明里的那把密钥协商,不和 Backend 转来的公钥协商;加密字段缺失就拒绝,不降级;能开哪些 extension、哪些 passkey 能授权,只看本节点属主。
- 谁来强制:节点本地。web ssh 这类会话要不要加密、要不要验证,由 Agent 自己要求。让 Backend 决定,被攻破的 Backend 一句「这次不用加密」就绕过去了。节点本地强制:没有操作员的现场证明不启动会话;会话密钥只和证明里的那把密钥协商,不和 Backend 转来的公钥协商;加密字段缺失就拒绝,不降级;能开哪些 extension、哪些 passkey 能授权,只看本节点属主。
- 证明的方向是反的:节点先出题(nonce 加它的临时公钥,用节点身份密钥签名),属主的浏览器把 nonce 和自己的临时公钥放进 passkey 断言的挑战里;节点验断言来自本节点属主、要求用户验证、nonce 是自己刚出的且没用过。Backend 仍然管谁有资格发起请求、审计、限流、转发密文。
- 交互式会话(终端、ssh)用现场签名的临时 ECDH 密钥协商,passkey 只签名授权,不参与解密,所以不支持 PRF 的 passkey 也能用,还有前向保密;之后双向是带序号的 AEAD。静态数据(抓包文件、会话录像)需要事后解密,才用属主记录里的接收者公钥加密(来自 WebAuthn PRF 或浏览器里不可导出的密钥)。
- 删除和使用分开:admin 能删节点、卸载 extension,读不到也用不了。改属主要现有属主的 passkey 签名(`ControllerUpdate`),账户被删、角色被改都改不了某个节点的属主。属主是 passkey 凭证,不是账户;一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,所以添加时建议绑两把以上。
@@ -182,7 +182,7 @@ macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就
④ 其余
- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
- 角色(Agent 还是 拨号方)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
- macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要证书,目前没有做。
## 11. 运行中的二进制不做校验
+33 -63
View File
@@ -1,66 +1,34 @@
# MoShiro 的注册与身份
通用的注册、身份、认证机制由 Fleet 提供,见 [Fleet 文档](../../backend/fleet/README.md) 的「身份」「注册」两节。本文只描述 MoShiro 在 Fleet 之上的约定:Agent 注册后如何自动接入测量、Probe 的身份以及 Agent 列表同步,以及节点本地的硬上限。
通用注册、认证和信任链由 [Fleet](../../backend/fleet/README.md) 提供。MoShiro 统一使用 Agent 节点;监听方、拨号方描述一次测量会话中的方向,同一 Agent 可以同时承担两侧工作。
## 1. 身份一览
## 1. 身份
| 主体 | 凭据 | 生成方 | Backend 保存 |
| --- | --- | --- | --- |
| Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 |
| Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 |
| 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 |
| API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 |
| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在宿主机的更新器里配置公钥(Backend 和脚本装的节点用 `moshiro-update`,见 [deployment.md](../ops/deployment.md);Quadlet 节点不验证清单,只是退出后重新拉镜像;纯二进制节点的更新见[路线图](../roadmap.md)),面板不保存私钥;登记清单时面板用公钥验签,宿主机的更新器之后再验一次 |
`fleet-enroll` 在节点本地生成连接 Backend 的 ECDSA P-256 身份密钥,以及 TLS 证书中的独立对等身份密钥。拨号和监听共用该节点的对等身份。Backend 保存公钥与 SPKI 指纹,不接收私钥。节点固定操作员集合的创世指纹和属主,并在注册、连接及状态同步时验证信任链。
## 2. Agent 接入流程
面板用户通过密码或 Passkey 登录;API 令牌带有明确的能力集合,服务端保存令牌哈希。发布清单使用独立发布密钥签名,该私钥不进入面板或仓库。更新器在节点进程之外执行,具体边界见 [deployment.md](../ops/deployment.md) 和 [access.md](access.md)。
## 2. 接入
```text
管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve, controllers} ──> 面板(Fleet.Server.Enrollment)
<── 一段 Quadlet 安装命令(默认,Podman 系;Docker 另有 compose 命令):把面板地址、一次性码、创世指纹和属主写进本地 env,下载面板提供的静态
Quadlet 单元并核对 SHA-256,再启动用户服务;命令里只有公开的值(见 access.md 第 10 节)。
备用:一行安装脚本(先核对脚本哈希再执行),参数走环境变量
两条路径做的事一样:容器的入口脚本,或 install.sh / install.ps1(packaging/install/,嵌入 Backend,静态文件,不替换任何值):
├─ 向面板取 CI 签名的发布清单,用脚本里内置的发布公钥验签,按清单里的 digest 拉镜像
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
└─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers …
├─ 先验操作员集合链(创世指纹、属主在集合里),验不过就不交出密钥
├─ 注册元数据(MoShiro 定义):{ probe_tcp_port, probe_quic_ports, public_host?, kinds[] }
└─ 写 trust.json(创世指纹、链、属主)和 agent.json(本地硬上限)
下载压缩包的二进制安装(packaging/install.sh)做同样的注册,另外创建系统用户 moshiro、启用 moshiro-agent.service,见 deployment.md。
面板收到 OnEnrolled(MoShiro 挂钩)
├─ public_host 未提供时,来源 IP 是公网地址才用;之后 hello 里 Agent 上报本机公网地址(`addresses`),面板自动填 public_host 和 ipv6_host,手填的优先
├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet)
└─ 面板把新的 Agent 列表作为期望状态推给所有 Probe → Probe 自动连接新 Agent
从安装到开始出数据,全程不需要手工配置
管理端创建加入令牌(统一 agent)
-> 安装命令携带面板地址、一次性码、创世指纹、属主和脚本哈希
-> fleet-enroll 验证信任链并在本机生成身份
-> Agent 连接 Backend,上报监听信息与本地能力
-> Backend 推送 task_set 和 dial_targets
-> Agent 调和拨出连接,同时接受名册内其他 Agent 的接入
```
名称冲突、审批、接管、禁用等行为都采用 Fleet 的默认规则。
同名节点进入待审批状态,由管理员决定是否接管。激活、停用、接管和删除通过统一的 Agent API 完成。
## 3. Probe 身份与 Agent 列表同步
Backend 默认启用 `fleet.auto_link`,所有 Agent 向其他可拨号 Agent 建立线路,自连接被排除。A→B、B→A 是两个独立方向;手动线路开关优先于自动连接。节点本地 `listen.enabled` 默认 true,可显式关闭,不产生另一种节点角色。
Probe 直接连面板(Fleet 长连接,身份是下面生成的密钥),Agent 列表由面板作为期望状态下发;测量数据仍然只走 Probe → Agent。
## 3. 任务、拨号目标与状态
```text
面板:POST /api/admin/join-tokens {role: "probe"} → 一次性加入令牌 jt_…
Probe 机器:fleet-enroll --server … --join jt_… --name home --identity-dir …
├─ 本地生成两把密钥:node.key(连面板认证)和 tls.pfx 里的密钥(连 Agent 对端认证);面板从不接触私钥
├─ 注册请求带两把公钥(pubkey、tls_pubkey)和持有证明;面板据此写 probes 记录,ID 前缀 prb_
└─ 吊销 /revoke 会让面板和所有 Agent 断开它;换密钥 = 吊销后重新注册(没有 rotate)
probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← limits 是本地硬上限,面板无法修改;在路由器上建议调低
Probe 启动
├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息
├─ 面板推送期望状态 probe_agents:{ version, agents: [ {agent_id, host, tcp_port, quic_ports, public_key}... ] }
├─ 调和:新增的 Agent → 建立点对点会话(peer 用途签名);被移除或禁用的 → 断开
└─ 之后列表的任何变化(Agent 注册、禁用、地址或证书变化),面板都重新推送同一种期望状态;
Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 agents.json(这是配置,不是测量数据;面板暂时连不上时,Probe 靠它继续测量)
```
- 每个 Agent 通过自己的期望状态(`TaskSet.peers`)获得 Probe 的公钥,用来验证点对点会话的签名。
- 哪些 Probe 连哪些 Agent 由面板决定,始终是 Probe 主动拨出:设置 `fleet.auto_link`(默认开)打开时,所有未吊销的 Probe 和所有 Agent 两两连;关闭时只连手动选中的。每一对还可以在管理端 Probe 页的「线路」矩阵里手动强制开或关(`PUT /api/admin/links/{probe_id}/{agent_id}`),手动设置优先于自动连接。结果同时用在 Probe 的 Agent 列表、Agent 的 `peers` 和线路目录(关掉的线路从总览里消失,历史数据还在)。
- 吊销 Probe:`POST /api/admin/probes/{id}/revoke`,旧公钥立即从所有 Agent 的期望状态中移除,旧会话随之断开;要继续用就重新注册。
- `TaskSet.peers` 下发允许接入的节点公钥。
- `DialTargets` 下发目标节点的地址、端口、证书公钥和 IP 路径。节点在 `dial_targets.json` 中缓存已验证的目标文档。
- 节点集合、地址、证书或线路策略变化后,Backend 重算并推送期望状态。节点断开已移除的目标,并连接新增目标。
- 每个 Agent 通过自身的 Backend 心跳上报运行状态、拨出线路的控制层和任务层健康、错误与重试,以及接入会话快照。测量流量走节点直连,监听方合并列块后上传原始数据。
## 4. Agent 本地配置(`/etc/moshiro/agent.json`,面板无法修改)
@@ -87,23 +55,25 @@ Probe 启动
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供 |
| GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供(升级为 WebSocket) |
| GET/POST/DELETE | `/api/admin/join-tokens` | 管理加入令牌 |
| POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批与启停 |
| GET/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 的列表、改名、删除 |
| POST | `/api/admin/probes/{id}/revoke` | 吊销(换密钥就是吊销之后重新注册,没有轮换) |
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe |
| GET | `/install`、`/install.ps1` | 静态安装脚本(Agent 和 Probe 共用,不带参数,哈希在一个版本内固定) |
| GET | `/install/manifest?code=…`、`/install/manifest.sig?code=…` | 凭一次性代码取该角色已签名的发布清单(原样,一个字节也不改) |
| POST | `/api/agent/v1/enroll` | 注册节点 |
| GET | `/api/agent/v1/rpc` | WebSocket 节点链路 |
| GET/POST/DELETE | `/api/admin/join-tokens` | 加入令牌 |
| GET | `/api/admin/agents` | 统一节点列表 |
| GET/PATCH/DELETE | `/api/admin/agents/{id}` | 节点详情、修改、删除 |
| POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批、启停与接管 |
| POST | `/api/admin/agents/{id}/dialers/{dialer_id}/disconnect` | 关闭指定接入会话 |
| GET | `/api/admin/links` | 有向线路矩阵与连接状态 |
| PUT | `/api/admin/links/{dialer_id}/{listener_id}` | 设置单方向线路策略 |
| POST | `/api/admin/links/{dialer_id}/{listener_id}/retry` | 开启并立即重试 |
| GET | `/install`、`/install.ps1` | 静态安装脚本 |
| GET | `/install/manifest?code=…`、`/install/manifest.sig?code=…` | 原样的签名发布清单及签名 |
## Agent 的本地配置:主机状态上报
`agent.json` 和 `probe.json` 的 `reporting` 段(面板无法修改):
`agent.json` 的 `reporting` 段(面板无法修改):
```json
{ "reporting": { "host_stats": true, "host_interval_s": 2 } }
```
`host_stats` 为 true(默认)时,Agent 和 Probe 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
`host_stats` 为 true(默认)时,Agent 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
+34 -34
View File
@@ -1,19 +1,19 @@
# 零信任设计
> 实现状态:协议方向声明表和卡口、Probe 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、Probe 拨号地址限制等还没做(第 3 节有逐项状态)。
> 实现状态:协议方向声明表和卡口、拨号方 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、拨号方 拨号地址限制等还没做(第 3 节有逐项状态)。
Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。账户、能力、passkey 和入网信任链的具体做法见 [access.md](access.md);节点能力边界、本地硬上限和签名更新见 [architecture.md](../design/architecture.md) 第 5 节,这里不重复。
Agent、Backend、拨号方 之间互相不信任。这份文档回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。账户、能力、passkey 和入网信任链的具体做法见 [access.md](access.md);节点能力边界、本地硬上限和签名更新见 [architecture.md](../design/architecture.md) 第 5 节,这里不重复。
## 1. 范围
要防的
- Backend(或 Backend 前面的 Cloudflare Tunnel、frp、Caddy 等中间环节)被攻破或被劫持之后,给节点下发恶意内容。
- Agent 或 Probe 自己被入侵之后,去影响 Backend 或对端。
- Agent 或 拨号方 自己被入侵之后,去影响 Backend 或对端。
有意不做的
- 进程沙箱(seccomp、pids 和内存上限、出站白名单)。Agent 和 Probe 的定位是 1 核 1 GB 的小机器,不能吃资源。
- 进程沙箱(seccomp、pids 和内存上限、出站白名单)。Agent 的定位是 1 核 1 GB 的小机器,不能吃资源。
- 供应链的多签、冷却期和可复现构建。代码风险靠 PR 评审降一个等级;发布仍然是单把发布密钥签名(见 [deployment.md](../ops/deployment.md))。
- 顺带记一条:Agent 的单元挂了 `/:/host/root:ro`、`/proc`、`/sys`,被攻破的 Agent 能遍历宿主机。它们多半是不必要的(宿主网络模式下容器自己的 `/proc` 就是宿主的,磁盘用量可以对已挂载的 `/etc/os-release` 做 `statvfs`),属于沙箱范畴,先不动。
@@ -23,7 +23,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
| --- | --- | --- |
| T1 | Backend,或它前面的中间环节 | 链路经过 Cloudflare Tunnel、frp、Caddy,它们都能看到 wss 的明文 |
| T2 | 某个 Agent(在 VPS 上,旁边还有别的服务) | VPS 被入侵 |
| T3 | 某个 Probe(在客户内网里) | 客户机器被入侵 |
| T3 | 某个 拨号方(在客户内网里) | 客户机器被入侵 |
| T4 | 管理员的会话或令牌 | 令牌泄露 |
| T5 | 管理员的浏览器 | XSS,或者节点上报的字符串被渲染 |
@@ -35,28 +35,28 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
管理员 ──令牌/会话──▶ ┌──────────┐ ◀── 原始块、事件、主机数字
│ Backend │
└──┬───┬───┘
任务集、对端公钥、 │ │ Agent 名单(host:port,Probe 全部照拨)
任务集、对端公钥、 │ │ Agent 名单(host:port,拨号方 全部照拨)
重启计划、更新清单 ▼ ▼
┌──────┐ ┌──────┐
│Agent │◀─▶│Probe │ Probe 私钥由节点自己生成
│Agent │◀─▶│拨号方 │ 拨号方 私钥由节点自己生成
└──────┘ └──────┘
```
| # | 缺口 | 状态 |
| --- | --- | --- |
| G1 | 节点不验证 Backend 的身份,只靠 TLS 加公共 CA | 已解决:操作员集合里登记服务器密钥,节点要求服务器在认证挑战里签名,链路用应用层加密([access.md](access.md) 第 8 节)。对 Backend 自己被攻破没有帮助 |
| G2 | Probe 的私钥由 Backend 生成 | 已解决:Probe 自己生成密钥,用加入令牌注册,私钥只在节点上(破坏性:旧 `probe.json` 失效) |
| G3 | Probe 会拨 Backend 给的任意 `host:port` | 未解决:被攻破的 Backend 能让客户内网里的 Probe 去探测内网;本地 `limits` 只有速率、pps 和 `allowed_target_cidrs` |
| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | 已解决:预握手敲门,见第 8 节(破坏性:旧 Probe 被拒绝) |
| G2 | 拨号方 的私钥由 Backend 生成 | 已解决:拨号方 自己生成密钥,用加入令牌注册,私钥只在节点上(破坏性:旧 `agent.json` 失效) |
| G3 | 拨号方 会拨 Backend 给的任意 `host:port` | 未解决:被攻破的 Backend 能让客户内网里的 拨号方 去探测内网;本地 `limits` 只有速率、pps 和 `allowed_target_cidrs` |
| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | 已解决:预握手敲门,见第 8 节(破坏性:旧 拨号方 被拒绝) |
| G5 | Backend 入库只限制了单块的大小,没有按节点的速率或配额 | 未解决:恶意节点可以灌满磁盘 |
| G6 | 对端公钥、Agent 地址全由 Backend 决定 | 未解决:操作员集合不包含成员名册,被攻破的 Backend 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent |
| G6 | 对端公钥、Agent 地址全由 Backend 决定 | 未解决:操作员集合不包含成员名册,被攻破的 Backend 能把恶意 拨号方 加进 Agent 的白名单,或把 拨号方 指向恶意 Agent |
| G7 | 方法的方向和角色没有统一声明 | 已解决:声明表和卡口,见第 6 节 |
| G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 Probe 侧的时间戳 |
| G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:<probe_id>`) | 未解决:被攻破的 Agent 可以为从没连过它的 Probe 灌入假线路数据 |
| G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 拨号方 侧的时间戳 |
| G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:<dialer_id>`) | 未解决:被攻破的 Agent 可以为从没连过它的 拨号方 灌入假线路数据 |
| G10 | 没有合理性检查(`EchoOps` 不检查回显时间戳的一致性) | 未解决:恶意 Agent 可以谎报 `t2`、`t3` |
| G11 | Probe 的一把密钥多处使用 | 已解决:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 |
| G11 | 拨号方 的一把密钥多处使用 | 已解决:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 |
没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);Probe 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。
没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);拨号方 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。
## 4. 信任锚点
@@ -65,7 +65,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
```text
① 发布密钥(CI) ──▶ 「跑什么代码」 清单签名、seq 防重放
② 操作员密钥(管理员 passkey)──▶ 「谁是操作员、哪个是合法的 Backend」 操作员集合,Backend 持有不了
③ 节点自己的密钥 ──▶ 「我是我」 Agent 和 Probe 都自己生成
③ 节点自己的密钥 ──▶ 「我是我」 Agent 都自己生成
被攻破的 Backend 能做的:拒绝服务、看数据、改成员名册和任务(G3、G6)。
不能做的:改操作员集合、冒充节点、塞恶意更新(发布清单要 CI 签名,节点的更新器还会再验)。
@@ -88,11 +88,11 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
外部面(人、浏览器、脚本) /api/public(匿名只读,SSE) /api/admin(Cookie + CSRF,或 API 令牌)
/install /openapi /healthz /metrics
节点面(节点主动拨入) /api/agent/v1/enroll(加入令牌,一次性) /api/agent/v1/rpc(wss,节点签名认证)
对等面(Probe 拨 Agent) TLS 控制 7443/tcp、数据 7443/udp、7444/tcp(先过敲门)
对等面(拨号方 拨 Agent) TLS 控制 7443/tcp、数据 7443/udp、7444/tcp(先过敲门)
```
- 每个平面只认自己的凭证:节点面不接受令牌和 Cookie;外部面不接受节点签名;对等面只认敲门通过的成员。
- 连接方向固定:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 Probe 拨 Agent,Agent 不拨 Probe。
- 连接方向固定:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 拨号方 拨 Agent,Agent 不拨 拨号方。
- 外部面不能直通节点面:管理端的操作(例如发起测试)只会触发 Backend 里一个强类型的下行方法,管理端不能指定方法名,也不能把请求体原样转给节点(`IAgentGateway` 里全是强类型方法)。
- 三个平面共用同一个监听端口和同一条 ASP.NET 管线,靠路径和凭证类型区分。把节点面和外部面做成可以分别配置监听地址(默认仍同一个),这样管理面可以只对内网或 VPN 开放,是待定的目标。
@@ -102,7 +102,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
强制方式:
1. 一份声明表,单一真源:框架的 `fleet.*` 在 `Fleet.Rpc/MethodTable.cs` 的 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→Probe)。
1. 一份声明表,单一真源:框架的 `fleet.*` 在 `Fleet.Rpc/MethodTable.cs` 的 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→拨号方)。
2. 卡口在 `RpcRouter`:创建时必须给出本侧(`RpcSide`)和声明表。注册处理函数时核对方向、类型、角色,不符就在启动时失败;分发时拒绝没有声明、方向不对、请求和通知用反了、角色不允许的消息,回 `fleet_protocol_violation`;发送前核对本侧有没有资格发起(违反是程序缺陷,抛异常)。
3. 违规处理:`RpcSession` 和服务端的 `NodeConnection` 对违规计数,超过上限(`MaxViolations`,默认 20)就断开连接;二进制通道也在服务端按声明核对。
4. 响应匹配:只接受对应未决请求 ID 的响应,不接受主动「响应」。
@@ -115,7 +115,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
| --- | --- | --- |
| 操作员集合新版本 | 操作员 passkey 签名,节点自己验 | 什么都改不了;能转发旧的合法版本,版本号递增防回放 |
| 更新清单 | 发布密钥(CI) | 不能伪造;能转发旧的合法清单,`seq` 防回滚;节点的更新器还会用本机公钥再验 |
| 成员名册、对端公钥、Agent 地址 | Backend(G3、G6 未解决) | 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent |
| 成员名册、对端公钥、Agent 地址 | Backend(G3、G6 未解决) | 能把恶意 拨号方 加进 Agent 的白名单,或把 拨号方 指向恶意 Agent |
| 任务集、测试请求 | Backend 计算;节点本地硬上限兜底 | 只能在本地速率和端口上限内发测量流量 |
| 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 |
| 未来的 `extension`(exec 等,尚未实现) | 属主 passkey 签名的一次性能力令牌,见第 9 节 | 设计目标是没有属主签名就无法调用 |
@@ -124,12 +124,12 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
### 7.1 测量数据的可信(G8、G9、G10)
测量数据是这个项目的产出,不能只靠传输层可信:链路有 Cloudflare 这样的中间环节,Agent 又要转发 Probe 的数据。
测量数据是这个项目的产出,不能只靠传输层可信:链路有 Cloudflare 这样的中间环节,Agent 又要转发 拨号方 的数据。
- 产生者签名:每个列块由 Probe 签名,每个原始块由上传它的节点签名;Agent 转发 Probe 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。
- 归属校验:入库时核对块里的 Probe 是和这个 Agent 有线路的成员,不是就丢弃并计数。
- 产生者签名:每个列块由 拨号方 签名,每个原始块由上传它的节点签名;Agent 转发 拨号方 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。
- 归属校验:入库时核对块里的 拨号方 是和这个 Agent 有线路的成员,不是就丢弃并计数。
- 合理性检查:`t3−t2` 必须小于 `t4−t1`,各时间戳单调;违反的样本不计入指标,记为异常并产生事件。
- 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 Probe 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,因为这一侧的真值只有它自己知道。
- 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 拨号方 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,因为这一侧的真值只有它自己知道。
### 7.2 对 Backend 的资源和输入
@@ -140,34 +140,34 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
### 7.3 对对端和身份
- 被攻破的 Probe 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
- Probe 只拨公网地址(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。
- 被攻破的 拨号方 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
- 拨号方 只拨公网地址(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 拨号方 本地拒绝私网、回环、链路本地地址。
- 名册里的主机名解析之后再检查地址:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。
- QUIC 会话迁移要做路径验证:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),由 QUIC path validation 提供。
- 不协商、不降级:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 Probe 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 拨号方 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
## 8. 监听隐身:预握手敲门
目标:Agent 的 TCP 端口对没有完成挑战的人不写出任何字节,包括 TLS 的 ServerHello,扫描器拿不到证书和 TLS 指纹(G4)。UDP 端口本来就是静默的。
```text
Probe Agent TCP 7443 / 7444
拨号方 Agent TCP 7443 / 7444
│── TCP 连接 ───────────────────────────────────▶ accept(内核已完成三次握手)
│── 敲门包 32 字节:nonce(16) │ tag(16) ─────────▶ 读 32 字节(限时 3 秒)
│ tag = HMAC(K_pair, 时间窗 ‖ nonce) 用名册里每个 Probe 的 K_pair 逐个核对
│ tag = HMAC(K_pair, 时间窗 ‖ nonce) 用名册里每个 拨号方 的 K_pair 逐个核对
│ ├ 合法:继续 TLS 握手 → 现有的签名认证
│◀──────────── TLS ServerHello ─────────────────── └ 不合法或超时:什么都不发,直接断开
```
- `K_pair = HKDF(ECDH(Probe 身份私钥, Agent 身份公钥), "moshiro-knock-v1")`:只有名册里的成员算得出;时间窗 30 秒,接受前后各一个窗;`nonce` 在窗内去重,防重放;每个来源地址限速(默认每来源每 10 秒 300 次)。
- 实现:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,Probe 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(Probe 的对等面公钥);`probe_agents` 下发的是 Agent 的 `public_key`(Agent 在 hello 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。
- `K_pair = HKDF(ECDH(拨号方 身份私钥, Agent 身份公钥), "moshiro-knock-v1")`:只有名册里的成员算得出;时间窗 30 秒,接受前后各一个窗;`nonce` 在窗内去重,防重放;每个来源地址限速(默认每来源每 10 秒 300 次)。
- 实现:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,拨号方 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(拨号方 的对等面公钥);`dial_targets` 下发的是 Agent 的 `public_key`(Agent 在 hello 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。
局限:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,不是「没监听」。真正的没监听有两条路:等级二的 UDP 单包授权(Probe 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。
局限:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,不是「没监听」。真正的没监听有两条路:等级二的 UDP 单包授权(拨号方 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。
## 9. extension:敏感能力的归宿
当前 Agent 和 Probe 的核心只包含测量与上报。敏感能力(exec、web ssh 和终端、文件读写、抓包等)计划做成 extension,由用户选择安装,默认不启用,目前都没有实现。
当前 Agent 的核心只包含测量与上报。敏感能力(exec、web ssh 和终端、文件读写、抓包等)计划做成 extension,由用户选择安装,默认不启用,目前都没有实现。
- 独立进程:单独的二进制和镜像,和核心之间没有进程内接口,核心不加载外部代码、不用反射和动态加载,核心的 NativeAOT 和构建期限制不受影响。
- 授权只认节点的属主:每次调用的能力令牌必须由这个节点绑定的属主 passkey 签名,账户角色不赋予权力;管理员只能删除节点或卸载 extension。交互式会话端到端加密,Backend 只转发密文。属主和会话密钥的设计见 [access.md](access.md) 第 9 节。
@@ -187,7 +187,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
| 令牌哈希、SPKI 指纹 | SHA-256 |
| 原始块完整性 | CRC32C,不是密码学校验 |
BLAKE3 的评估(已定:不采用):ECDSA P-256 本身用 SHA-256,令牌哈希和 SPKI 指纹也继续用 SHA-256,所以 BLAKE3 是多一个哈希族,不是换掉一个;BCL 里没有,Probe 和 Agent 不引用 NuGet 包,只能自己实现几百行的便携版本加官方测试向量,这是要长期维护的密码学代码;托管实现没有 SHA-NI 加速,在 x86 上未必更快。若以后在目标硬件上实测发现逐包 MAC 是瓶颈,再单独评估,届时只替换 `Fleet.Identity` 里 `Mac` 和 `Kdf` 两个静态函数,调用方不动。
BLAKE3 的评估(已定:不采用):ECDSA P-256 本身用 SHA-256,令牌哈希和 SPKI 指纹也继续用 SHA-256,所以 BLAKE3 是多一个哈希族,不是换掉一个;BCL 里没有,Agent 不引用 NuGet 包,只能自己实现几百行的便携版本加官方测试向量,这是要长期维护的密码学代码;托管实现没有 SHA-NI 加速,在 x86 上未必更快。若以后在目标硬件上实测发现逐包 MAC 是瓶颈,再单独评估,届时只替换 `Fleet.Identity` 里 `Mac` 和 `Kdf` 两个静态函数,调用方不动。
## 11. 还没定的事