Compare commits
43
Commits
af2962fc58
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725fe17a7d | ||
|
|
e49990776b | ||
|
|
f8e8bb8acb | ||
|
|
70f20d38d0 | ||
|
|
7ff03a9628 | ||
|
|
5dce387c87 | ||
|
|
535bb3517d | ||
|
|
88b1a0a9bb | ||
|
|
bac1c27e72 | ||
|
|
12be44645f | ||
|
|
e014a19554 | ||
|
|
636a784043 | ||
|
|
0f8f3f9a54 | ||
|
|
904ea2b0ae | ||
|
|
a4f3d1892a | ||
|
|
cab505b837 | ||
|
|
cd9e677331 | ||
|
|
879d1c8593 | ||
|
|
4a1e48db20 | ||
|
|
f22fdf8b68 | ||
|
|
2ae63653c4 | ||
|
|
8c37db1577 | ||
|
|
0b384a58f5 | ||
|
|
4841f7b15b | ||
|
|
117d34a2af | ||
|
|
4106f831dc | ||
|
|
f704c66dd9 | ||
|
|
fbda9487d5 | ||
|
|
4a3631f82b | ||
|
|
3b88a3419d | ||
|
|
a2f88a344b | ||
|
|
9bb6841dce | ||
|
|
fc2bbdcc6d | ||
|
|
321cacac32 | ||
|
|
36f9816f40 | ||
|
|
310b2b5b19 | ||
|
|
a19e6548d3 | ||
|
|
ff45ad086e | ||
|
|
1379b8c7cb | ||
|
|
359313a57b | ||
|
|
6385c1b9a3 | ||
|
|
ddb6608e32 | ||
|
|
55b80f0a97 |
No files matched your search
@@ -6,3 +6,4 @@ packaging/update/*.sh text eol=lf
|
||||
packaging/container/*.sh text eol=lf
|
||||
ci/release-node-images.sh text eol=lf
|
||||
packaging/install/*.sh text eol=lf
|
||||
packaging/install/*.ps1 text eol=lf
|
||||
@@ -27,6 +27,9 @@ steps:
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/release-node-images.sh
|
||||
depends_on: []
|
||||
@@ -37,6 +40,8 @@ steps:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/publish-gitea-release.sh
|
||||
depends_on:
|
||||
|
||||
@@ -15,6 +15,17 @@ when:
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
# 前端先打包好(几十秒),两个架构的 Backend 构建再并行:它们把 web/*/dist 内嵌进程序,只读这份产物,不再各自 npm ci
|
||||
# (并行的两个 npm ci 会同时删 node_modules,互相搞坏)。步骤加了超时:卡住就几分钟内失败,不是耗着。
|
||||
- name: web-build
|
||||
image: bash
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- timeout 600 npm run build --prefix web
|
||||
depends_on: []
|
||||
|
||||
- name: linux-amd64-backend
|
||||
image: bash
|
||||
environment:
|
||||
@@ -25,7 +36,8 @@ steps:
|
||||
from_secret: zot_secret
|
||||
commands:
|
||||
- bash ci/release-linux.sh amd64 backend
|
||||
depends_on: []
|
||||
depends_on:
|
||||
- web-build
|
||||
|
||||
- name: linux-arm64-backend
|
||||
image: bash
|
||||
@@ -37,7 +49,8 @@ steps:
|
||||
from_secret: zot_secret
|
||||
commands:
|
||||
- bash ci/release-linux.sh arm64 backend
|
||||
depends_on: []
|
||||
depends_on:
|
||||
- web-build
|
||||
|
||||
- name: backend-multiarch-index
|
||||
image: bash
|
||||
@@ -50,8 +63,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/index-backend-image.sh
|
||||
depends_on:
|
||||
|
||||
@@ -27,8 +27,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh backend
|
||||
depends_on: []
|
||||
@@ -44,8 +42,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh agent
|
||||
depends_on: []
|
||||
@@ -61,8 +57,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh probe
|
||||
depends_on: []
|
||||
@@ -9,7 +9,7 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
|
||||
- Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request.
|
||||
- A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
|
||||
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
|
||||
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests, `release_api_token` with the `release` scope). The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
||||
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
||||
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
|
||||
|
||||
## Project layout
|
||||
@@ -38,6 +38,6 @@ Follow the user's requested validation scope. Do not start a full tag/release bu
|
||||
|
||||
## CI and releases
|
||||
|
||||
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
|
||||
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/ops/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
|
||||
|
||||
A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. Development image builds and releases must remain isolated in their respective Zot repository paths.
|
||||
@@ -12,45 +12,54 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
||||
|
||||
**它不做什么**
|
||||
|
||||
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
|
||||
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
|
||||
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。
|
||||
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
|
||||
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
|
||||
- 暂不包含告警(见文末待办)。
|
||||
|
||||
## 信任关系
|
||||
|
||||
```text
|
||||
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
|
||||
管理员 ── 密码或 passkey(Cookie 会话 + CSRF),或 API 令牌(按能力授权)──▶ Backend
|
||||
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
|
||||
|
||||
操作员 ── passkey 给「操作员集合」签名 ──▶ Backend 只转发 ──▶ 节点自己验链
|
||||
(创世指纹和属主在安装时固定在节点上;集合里还登记了 Backend 的服务器密钥,节点据此认 Backend、加密链路)
|
||||
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
|
||||
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
|
||||
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
|
||||
Probe ── 先敲门,再用自己的密钥签名建立测量会话 ───────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
|
||||
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ 镜像仓库 / Backend ──▶ 节点宿主机的更新器用本机公钥验签
|
||||
```
|
||||
|
||||
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
|
||||
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
|
||||
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
|
||||
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。
|
||||
- **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。
|
||||
- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。
|
||||
- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
Web(展示端 · 管理端)
|
||||
▲ REST + SSE
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Backend(面板) │
|
||||
│ Api · Registry · Planner · Links │
|
||||
│ Releases · Analysis · LiveHub │
|
||||
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像)
|
||||
▼ ▲
|
||||
Web(展示端 · 管理端) │ 定时取清单
|
||||
▲ REST + SSE │
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Backend(面板) │──────────────────────────────┘
|
||||
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │
|
||||
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │
|
||||
│ Releases(发布清单 · 可信发布密钥) │
|
||||
│ Ingest · Analysis · LiveHub │
|
||||
│ Store(段文件 + SQLite) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ wss 长连接(节点发起) │ wss 长连接(节点发起)
|
||||
│ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密)
|
||||
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
||||
│ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │
|
||||
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||
```
|
||||
|
||||
两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
|
||||
@@ -60,7 +69,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
||||
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web
|
||||
```
|
||||
|
||||
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/architecture.md`](docs/architecture.md)。**
|
||||
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/design/architecture.md`](docs/design/architecture.md)。**
|
||||
|
||||
## 协议
|
||||
|
||||
@@ -69,7 +78,7 @@ Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配
|
||||
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
|
||||
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
|
||||
|
||||
细节见 [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api.md`](docs/api.md)、[`docs/enrollment.md`](docs/enrollment.md)。
|
||||
细节见 [`docs/design/protocol.md`](docs/design/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api/api.md`](docs/api/api.md)、[`docs/security/enrollment.md`](docs/security/enrollment.md)。
|
||||
|
||||
## 如何构建
|
||||
|
||||
@@ -80,7 +89,7 @@ dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet
|
||||
npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist
|
||||
```
|
||||
|
||||
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
|
||||
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。第一次打开管理端会进认领向导,认领码在后端日志里(或用 `MoShiro__ClaimCode` 指定)。
|
||||
|
||||
测试和检查:
|
||||
|
||||
@@ -90,21 +99,19 @@ npm run lint:css --prefix web # 前端样式(颜色只能写
|
||||
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
|
||||
```
|
||||
|
||||
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/deployment.md`](docs/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
|
||||
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/ops/deployment.md`](docs/ops/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
|
||||
| [`docs/security.md`](docs/security.md) | 零信任设计稿(尚未实现):威胁模型、名册和操作员凭证、监听隐身、extension 授权 |
|
||||
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
|
||||
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
|
||||
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
|
||||
| [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 |
|
||||
| [`docs/api.md`](docs/api.md)、[`docs/api-guidelines.md`](docs/api-guidelines.md) | API 规范与约束 |
|
||||
| [`docs/deployment.md`](docs/deployment.md) | 构建、发布、CI、部署与更新 |
|
||||
| [`docs/project-layout.md`](docs/project-layout.md)、[`docs/coding-style.md`](docs/coding-style.md) | 仓库结构与依赖规则、代码风格 |
|
||||
| [`docs/README.md`](docs/README.md) | 文档索引,按类别组织 |
|
||||
| [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 |
|
||||
| [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 |
|
||||
| [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 |
|
||||
| [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 |
|
||||
| [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 |
|
||||
| [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) |
|
||||
|
||||
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
## 任务与面板
|
||||
|
||||
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
|
||||
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security.md`](docs/security.md) 第 9.2 节
|
||||
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节
|
||||
|
||||
## 部署
|
||||
|
||||
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
|
||||
[security.md](docs/security.md)
|
||||
[security.md](docs/security/security.md)
|
||||
## 访客页面
|
||||
|
||||
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
|
||||
@@ -34,11 +34,11 @@
|
||||
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
|
||||
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
|
||||
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。
|
||||
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/deployment.md`](docs/deployment.md)。
|
||||
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/ops/deployment.md`](docs/ops/deployment.md)。
|
||||
|
||||
## 安全
|
||||
|
||||
- **零信任**:设计稿在 [`docs/security.md`](docs/security.md),按其中第 12 节的顺序实现(阶段零「协议方向声明表」已完成):Probe 自己生成密钥、Agent 预握手敲门、操作员凭证和名册、测量数据的签名和归属校验、入库配额和管理端 CSP、Backend 应用层身份、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT);可选的 UDP 单包授权、UDP 会话迁移的路径验证。
|
||||
- **零信任**:设计和各缺口的状态在 [`docs/security/security.md`](docs/security/security.md)。已完成:协议方向声明表、Probe 自己生成密钥、Agent 预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定。还没做:测量数据的产生者签名和归属校验、入库配额、Probe 只拨公网地址、管理端 CSP 之外的输入校验、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT)、吊销表;可选的 UDP 单包授权、UDP 会话迁移的路径验证。
|
||||
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
|
||||
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||
<Deterministic>true</Deterministic>
|
||||
<Version>0.1.1</Version>
|
||||
<Version>0.4.0</Version>
|
||||
<NuGetAudit>false</NuGetAudit>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ dotnet test --solution MoShiro.slnx
|
||||
dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。
|
||||
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。
|
||||
|
||||
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
|
||||
|
||||
@@ -49,6 +49,6 @@ cd backend
|
||||
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
|
||||
```
|
||||
|
||||
首次启动时可用 `MoShiro__InitialAdminUser` 和 `MoShiro__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
|
||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api/api.md`](../docs/api/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||
@@ -29,7 +29,7 @@ dotnet publish agent/src/MoShiro.Agent -c Release -r <RID> -p:PublishAot=true
|
||||
dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
## 注册与运行
|
||||
|
||||
@@ -47,7 +47,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
|
||||
moshiro-agent --config /etc/moshiro/agent.json
|
||||
```
|
||||
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
|
||||
|
||||
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
|
||||
|
||||
@@ -58,6 +58,6 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为
|
||||
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
|
||||
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
|
||||
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
|
||||
- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
- 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
@@ -18,6 +18,7 @@ public sealed class AgentApp : IDisposable
|
||||
private readonly AgentConfig _config;
|
||||
private readonly bool _ipv6Supported;
|
||||
private readonly KnockGate _knock;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly RestartController _restart;
|
||||
private readonly RestartController? _ownedRestart;
|
||||
private readonly LoadedNodeIdentity _identity;
|
||||
@@ -30,7 +31,7 @@ public sealed class AgentApp : IDisposable
|
||||
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly AgentAdmission _admission;
|
||||
private readonly Outbox _outbox = new(64 << 20);
|
||||
private readonly UpdateBridge _update;
|
||||
private readonly RestartUpdater? _update;
|
||||
private readonly BackendUplink _uplink;
|
||||
private readonly UdpMux _udp;
|
||||
private readonly TcpAcceptor _tcp;
|
||||
@@ -45,7 +46,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
_ownedRestart = restart is null ? new RestartController() : null;
|
||||
_restart = restart ?? _ownedRestart!;
|
||||
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
||||
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
|
||||
_config = config;
|
||||
_identity = identity;
|
||||
_time = time;
|
||||
@@ -63,6 +64,7 @@ public sealed class AgentApp : IDisposable
|
||||
_admission = new AgentAdmission(_streams);
|
||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
||||
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
||||
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
|
||||
|
||||
@@ -81,7 +83,7 @@ public sealed class AgentApp : IDisposable
|
||||
};
|
||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
||||
|
||||
@@ -123,6 +125,9 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
public int ControlPort => _peers.Port;
|
||||
|
||||
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
|
||||
public int TrustVersion => _trust.Version;
|
||||
|
||||
public AgentCounters Counters => _counters;
|
||||
|
||||
public FleetLink Link => _link;
|
||||
@@ -230,7 +235,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||
{
|
||||
_update.WriteOffer(offer);
|
||||
_update?.Offer(offer);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -252,7 +257,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
|
||||
{
|
||||
_update.RequestApply();
|
||||
_update?.RequestApply();
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -270,6 +275,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
Server = new Uri(_config.Fleet.Server),
|
||||
Identity = _identity.Identity,
|
||||
ServerVerifier = _trust,
|
||||
NodeId = _identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
||||
@@ -296,6 +302,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
var meta = new AgentHelloMeta
|
||||
{
|
||||
Owners = [.. _trust.Controllers],
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Ipv6Supported = _ipv6Supported,
|
||||
Addresses = [.. LocalAddresses.Sample()],
|
||||
@@ -334,7 +341,7 @@ public sealed class AgentApp : IDisposable
|
||||
DroppedInvalid = _counters.DroppedInvalid,
|
||||
DroppedRateLimited = _counters.DroppedRateLimited,
|
||||
QueueBytes = _outbox.PendingBytes,
|
||||
Update = _update.ReadStatus(),
|
||||
Update = _update?.Status(),
|
||||
};
|
||||
_lastIn = packetsIn;
|
||||
_lastOut = packetsOut;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Measurement;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
@@ -28,16 +29,6 @@ public sealed class AgentConfig
|
||||
public string? RestartAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReportingSection
|
||||
{
|
||||
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
|
||||
[JsonPropertyName("host_stats")]
|
||||
public bool HostStats { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("host_interval_s")]
|
||||
public int HostIntervalSeconds { get; set; } = 2;
|
||||
}
|
||||
|
||||
public sealed class FleetSection
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
@@ -45,6 +36,10 @@ public sealed class FleetSection
|
||||
|
||||
[JsonPropertyName("identity_dir")]
|
||||
public string IdentityDir { get; set; } = "/etc/moshiro";
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
|
||||
[JsonPropertyName("require_owner")]
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class ListenSection
|
||||
|
||||
@@ -213,8 +213,6 @@ public sealed class PeerHub
|
||||
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
|
||||
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
|
||||
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ try
|
||||
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
|
||||
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
|
||||
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
|
||||
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
|
||||
{
|
||||
identity.Dispose();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Measurement;
|
||||
@@ -25,14 +26,16 @@ public sealed class TaskManager
|
||||
private readonly TargetRunners _targets;
|
||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
|
||||
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
|
||||
private long _version;
|
||||
private BudgetGuard? _guard;
|
||||
|
||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts)
|
||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
||||
{
|
||||
_trust = trust ?? TrustAnchor.None;
|
||||
_guardFactory = guardFactory;
|
||||
_streams = streams;
|
||||
_env = env;
|
||||
@@ -98,6 +101,8 @@ public sealed class TaskManager
|
||||
_ = ct;
|
||||
var result = new StateSyncResult { AppliedVersion = version };
|
||||
|
||||
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
|
||||
_trust.Update(set.TrustChain);
|
||||
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
foreach (PeerSpec peer in set.Peers)
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
|
||||
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
|
||||
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/deployment.md`](../../docs/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
|
||||
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
|
||||
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
|
||||
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
|
||||
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 8 节。
|
||||
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
|
||||
|
||||
| 保留方法 | 方向 | 说明 |
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
|
||||
public sealed class SignedStatementDto
|
||||
{
|
||||
[JsonPropertyName("statement")]
|
||||
public string Statement { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("credential_id")]
|
||||
public string CredentialId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("authenticator_data")]
|
||||
public string AuthenticatorData { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("client_data_json")]
|
||||
public string ClientDataJson { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
|
||||
public sealed class TrustBundle
|
||||
{
|
||||
[JsonPropertyName("chain")]
|
||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||
}
|
||||
@@ -51,16 +51,4 @@ public static class VarInt
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
|
||||
{
|
||||
if (source.Length < 4)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Enrollment.Client;
|
||||
|
||||
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
|
||||
[JsonSerializable(typeof(TrustBundle))]
|
||||
internal sealed partial class TrustBundleContext : JsonSerializerContext;
|
||||
|
||||
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false);
|
||||
|
||||
/// <summary>
|
||||
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
|
||||
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
|
||||
public static class EnrollmentClient
|
||||
{
|
||||
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
||||
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
|
||||
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
|
||||
string? genesis = null, IReadOnlyList<string>? controllers = null)
|
||||
{
|
||||
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
||||
if (File.Exists(nodeFile))
|
||||
@@ -36,6 +41,24 @@ public static class EnrollmentClient
|
||||
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
|
||||
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
|
||||
TrustBundle? bundle = null;
|
||||
if (genesis is not null)
|
||||
{
|
||||
if (controllers is not { Count: > 0 })
|
||||
{
|
||||
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
|
||||
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
|
||||
}
|
||||
|
||||
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
|
||||
if (!trusted.TryGet(out bundle, out FleetError? trustError))
|
||||
{
|
||||
return Result<EnrollmentResult>.Fail(trustError);
|
||||
}
|
||||
}
|
||||
|
||||
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
||||
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
|
||||
var body = new JsonObject
|
||||
@@ -76,10 +99,46 @@ public static class EnrollmentClient
|
||||
var store = new ConfigStore(identityDirectory);
|
||||
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
||||
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
||||
if (bundle is not null)
|
||||
{
|
||||
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
|
||||
}
|
||||
|
||||
// node.json 最后写:它存在就表示注册完整完成
|
||||
store.Write(
|
||||
LoadedNodeIdentity.NodeFileName,
|
||||
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
|
||||
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
|
||||
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
|
||||
}
|
||||
|
||||
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
|
||||
{
|
||||
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
|
||||
|
||||
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
|
||||
}
|
||||
|
||||
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
|
||||
if (bundle is null || bundle.Chain.Count == 0)
|
||||
{
|
||||
return Refuse("the panel has no operator trust root set up");
|
||||
}
|
||||
|
||||
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
|
||||
if (current is null)
|
||||
{
|
||||
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
|
||||
}
|
||||
|
||||
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
|
||||
{
|
||||
return Refuse("an owner passkey in the install command is not in the operator set");
|
||||
}
|
||||
|
||||
return Result<TrustBundle>.Ok(bundle);
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ public static class ServiceShell
|
||||
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
|
||||
CancellationToken changed = restart.ChangeToken;
|
||||
RestartSchedule? schedule = restart.Current;
|
||||
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
|
||||
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
|
||||
try
|
||||
{
|
||||
if (schedule is null)
|
||||
@@ -196,6 +196,13 @@ public static class ServiceShell
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (restart.NowReason is { } reason)
|
||||
{
|
||||
log.Info("stopping", ("result", reason));
|
||||
await cts.CancelAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
|
||||
/// <para>
|
||||
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
|
||||
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
|
||||
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class OwnerGuard
|
||||
{
|
||||
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
|
||||
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
|
||||
|
||||
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
|
||||
public static string? Problem(TrustAnchor trust, bool require)
|
||||
{
|
||||
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trust.Pinned)
|
||||
{
|
||||
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
|
||||
}
|
||||
|
||||
return trust.Controllers.Count == 0
|
||||
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
|
||||
public static bool Check(string identityDirectory, bool require, INodeLog log)
|
||||
{
|
||||
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
|
||||
if (Problem(trust, require) is { } problem)
|
||||
{
|
||||
log.Error("startup failed", ("err", problem));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ public sealed class RestartController : IDisposable
|
||||
private readonly RestartSchedule? _local;
|
||||
private readonly object _gate = new();
|
||||
private RestartSchedule? _remote;
|
||||
private readonly CancellationTokenSource _now = new();
|
||||
private CancellationTokenSource _changed = new();
|
||||
private string? _nowReason;
|
||||
|
||||
public RestartController(RestartSchedule? local = null)
|
||||
{
|
||||
@@ -41,6 +43,32 @@ public sealed class RestartController : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用要求现在就有序退出(比如管理员请求更新);退出的原因,没有就是 null。</summary>
|
||||
public string? NowReason
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _nowReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用要求现在就退出的令牌。</summary>
|
||||
public CancellationToken NowToken => _now.Token;
|
||||
|
||||
/// <summary>要求进程现在就有序退出,由服务管理器拉起;不管有没有设置定时重启。</summary>
|
||||
public void RequestNow(string reason)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_nowReason ??= reason;
|
||||
}
|
||||
|
||||
_now.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
|
||||
public bool SetRemote(string? text)
|
||||
{
|
||||
@@ -68,6 +96,7 @@ public sealed class RestartController : IDisposable
|
||||
lock (_gate)
|
||||
{
|
||||
_changed.Dispose();
|
||||
_now.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
|
||||
public sealed class TrustFile
|
||||
{
|
||||
[JsonPropertyName("genesis")]
|
||||
public string Genesis { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chain")]
|
||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||
|
||||
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
|
||||
[JsonPropertyName("controllers")]
|
||||
public List<string> Controllers { get; set; } = [];
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(TrustFile))]
|
||||
public sealed partial class TrustFileContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
|
||||
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
||||
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
|
||||
/// </summary>
|
||||
public sealed class TrustAnchor : IServerVerifier
|
||||
{
|
||||
public const string FileName = "trust.json";
|
||||
|
||||
private readonly ConfigStore? _store;
|
||||
private readonly INodeLog _log;
|
||||
private readonly object _gate = new();
|
||||
private readonly TrustFile? _file;
|
||||
private OperatorSet? _current;
|
||||
|
||||
private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log)
|
||||
{
|
||||
_store = store;
|
||||
_file = file;
|
||||
_current = current;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
|
||||
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
|
||||
|
||||
public bool Pinned => _file is not null;
|
||||
|
||||
public string? Genesis => _file?.Genesis;
|
||||
|
||||
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
|
||||
public int Version
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _current?.Version ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
|
||||
public bool Required
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _current?.Servers is { Count: > 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
|
||||
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
IReadOnlyList<byte[]> servers;
|
||||
lock (_gate)
|
||||
{
|
||||
servers = _current?.Servers ?? [];
|
||||
}
|
||||
|
||||
foreach (byte[] server in servers)
|
||||
{
|
||||
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
|
||||
public IReadOnlyList<string> Controllers
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static TrustAnchor Load(string identityDirectory, INodeLog log)
|
||||
{
|
||||
string path = Path.Combine(identityDirectory, FileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log);
|
||||
}
|
||||
|
||||
TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile);
|
||||
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
|
||||
if (current is null)
|
||||
{
|
||||
// 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
|
||||
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
|
||||
}
|
||||
|
||||
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
|
||||
}
|
||||
|
||||
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
|
||||
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
|
||||
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
|
||||
|
||||
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
|
||||
public int Update(IReadOnlyList<SignedStatementDto>? chain)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_file is null || chain is not { Count: > 0 })
|
||||
{
|
||||
return _current?.Version ?? 0;
|
||||
}
|
||||
|
||||
OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]);
|
||||
if (candidate is null)
|
||||
{
|
||||
_log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it");
|
||||
return _current!.Version;
|
||||
}
|
||||
|
||||
if (candidate.Version > _current!.Version)
|
||||
{
|
||||
_file.Chain = [.. chain];
|
||||
_current = candidate;
|
||||
try
|
||||
{
|
||||
_store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not save trust.json", ("err", ex.Message));
|
||||
}
|
||||
|
||||
_log.Info("operator set updated", ("version", candidate.Version));
|
||||
}
|
||||
|
||||
return _current.Version;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
|
||||
public static class SignaturePurpose
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
|
||||
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
|
||||
public const string AuthSecure = "auth2";
|
||||
public const string Peer = "peer";
|
||||
public const string Enroll = "enroll";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
|
||||
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||
|
||||
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
|
||||
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
||||
|
||||
/// <summary>
|
||||
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
|
||||
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
|
||||
/// </summary>
|
||||
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
|
||||
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
|
||||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
|
||||
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||||
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||||
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||||
/// </summary>
|
||||
public static class OperatorTrust
|
||||
{
|
||||
public const string SetHeader = "moshiro-opset-v1";
|
||||
public const string Genesis = "0";
|
||||
|
||||
public static string Encode(OperatorSet set)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append(SetHeader).Append('\n')
|
||||
.Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
|
||||
.Append("prev=").Append(set.Previous).Append('\n')
|
||||
.Append("rp=").Append(set.RpId).Append('\n')
|
||||
.Append("origins=").Append(string.Join(',', set.Origins));
|
||||
foreach (OperatorKey key in set.Keys)
|
||||
{
|
||||
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
|
||||
}
|
||||
|
||||
foreach (byte[] server in set.Servers ?? [])
|
||||
{
|
||||
text.Append("\nserver=").Append(Convert.ToBase64String(server));
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
|
||||
public static OperatorSet? ParseSet(string text)
|
||||
{
|
||||
string[] lines = text.Split('\n');
|
||||
if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
|
||||
|| !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keys = new List<OperatorKey>();
|
||||
var servers = new List<byte[]>();
|
||||
foreach (string line in lines.Skip(5))
|
||||
{
|
||||
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
|
||||
{
|
||||
keys.Add(new OperatorKey(value[..split], spki));
|
||||
}
|
||||
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
|
||||
{
|
||||
servers.Add(serverKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
|
||||
}
|
||||
|
||||
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
|
||||
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
||||
|
||||
/// <summary>
|
||||
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
|
||||
/// 返回最后一版;不对返回 null。
|
||||
/// </summary>
|
||||
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> chain)
|
||||
{
|
||||
if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
OperatorSet? current = null;
|
||||
string? previousFingerprint = null;
|
||||
foreach (SignedStatement link in chain)
|
||||
{
|
||||
OperatorSet? next = ParseSet(link.Statement);
|
||||
if (next is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
if (next.Version != 1 || !VerifyOperator(link, next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
current = next;
|
||||
previousFingerprint = Fingerprint(link.Statement);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
|
||||
public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
|
||||
{
|
||||
OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
|
||||
if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
|
||||
}
|
||||
|
||||
private static bool TryValue(string line, string key, out string value)
|
||||
{
|
||||
value = "";
|
||||
if (!line.StartsWith(key + "=", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = line[(key.Length + 1)..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryBase64(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return data.Length > 0;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
data = [];
|
||||
return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
|
||||
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
|
||||
/// </summary>
|
||||
public interface IServerVerifier
|
||||
{
|
||||
bool Required { get; }
|
||||
|
||||
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
|
||||
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
|
||||
}
|
||||
|
||||
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
|
||||
public static class ServerHello
|
||||
{
|
||||
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
|
||||
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
|
||||
|
||||
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
|
||||
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
using ECDsa key = ECDsa.Create();
|
||||
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
|
||||
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
|
||||
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
|
||||
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
|
||||
/// </summary>
|
||||
public sealed class LinkCipher : IDisposable
|
||||
{
|
||||
public const int Overhead = 8 + 16;
|
||||
|
||||
private const byte NodeToServer = 0;
|
||||
private const byte ServerToNode = 1;
|
||||
|
||||
private readonly AesGcm _send;
|
||||
private readonly AesGcm _receive;
|
||||
private readonly byte _sendDirection;
|
||||
private readonly byte _receiveDirection;
|
||||
private readonly object _gate = new();
|
||||
private long _sendCounter;
|
||||
private long _receiveCounter;
|
||||
|
||||
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
|
||||
{
|
||||
_send = new AesGcm(sendKey, 16);
|
||||
_receive = new AesGcm(receiveKey, 16);
|
||||
_sendDirection = sendDirection;
|
||||
_receiveDirection = receiveDirection;
|
||||
}
|
||||
|
||||
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
|
||||
{
|
||||
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
|
||||
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
|
||||
}
|
||||
|
||||
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
|
||||
{
|
||||
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
|
||||
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
|
||||
}
|
||||
|
||||
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
|
||||
{
|
||||
byte[] secret = own.AgreeRaw(remote);
|
||||
try
|
||||
{
|
||||
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
|
||||
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
|
||||
return (material[..32], material[32..]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(secret);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>加密一帧。</summary>
|
||||
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
|
||||
{
|
||||
long counter;
|
||||
lock (_gate)
|
||||
{
|
||||
counter = _sendCounter++;
|
||||
}
|
||||
|
||||
byte[] output = new byte[Overhead + 1 + data.Length];
|
||||
BinaryPrimitives.WriteInt64BigEndian(output, counter);
|
||||
byte[] plaintext = new byte[1 + data.Length];
|
||||
plaintext[0] = kind;
|
||||
data.CopyTo(plaintext.AsSpan(1));
|
||||
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
|
||||
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
|
||||
{
|
||||
kind = 0;
|
||||
data = [];
|
||||
if (frame.Length < Overhead + 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
|
||||
lock (_gate)
|
||||
{
|
||||
if (counter != _receiveCounter)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int length = frame.Length - Overhead;
|
||||
byte[] plaintext = new byte[length];
|
||||
try
|
||||
{
|
||||
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_receiveCounter++;
|
||||
kind = plaintext[0];
|
||||
data = plaintext[1..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Nonce(long counter)
|
||||
{
|
||||
byte[] nonce = new byte[12];
|
||||
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
private static byte[] Aad(byte direction, long counter)
|
||||
{
|
||||
byte[] aad = new byte[9];
|
||||
aad[0] = direction;
|
||||
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
|
||||
return aad;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_send.Dispose();
|
||||
_receive.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
|
||||
|
||||
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
|
||||
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
|
||||
{
|
||||
using ECDiffieHellman remote = ECDiffieHellman.Create();
|
||||
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
|
||||
return _key.DeriveRawSecretAgreement(remote.PublicKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
||||
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
public static class TrustMapping
|
||||
{
|
||||
public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature);
|
||||
|
||||
public static SignedStatementDto ToDto(this SignedStatement signed) =>
|
||||
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
|
||||
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
|
||||
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
|
||||
/// </summary>
|
||||
public static class WebAuthnAssertion
|
||||
{
|
||||
public const byte FlagUserPresent = 0x01;
|
||||
public const byte FlagUserVerified = 0x04;
|
||||
|
||||
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
|
||||
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
|
||||
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
|
||||
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
|
||||
public static bool Verify(
|
||||
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
|
||||
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed, bool requireUserVerification, out uint signCount)
|
||||
{
|
||||
signCount = 0;
|
||||
if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte flags = authenticatorData[32];
|
||||
if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using ECDsa key = ECDsa.Create();
|
||||
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
|
||||
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
|
||||
if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
|
||||
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| root.GetProperty("type").GetString() != expectedType
|
||||
|| root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
|
||||
|| root.GetProperty("challenge").GetString() is not { } challenge)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] decoded = Base64Url.DecodeFromChars(challenge);
|
||||
return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,12 @@ public sealed class FleetLinkOptions
|
||||
public long MaxInflightBytes { get; init; } = 4 << 20;
|
||||
|
||||
public Backoff Backoff { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
|
||||
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||
/// </summary>
|
||||
public IServerVerifier? ServerVerifier { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -77,6 +83,7 @@ public sealed class FleetLink
|
||||
private const string RpcPath = "/api/agent/v1/rpc";
|
||||
|
||||
private readonly FleetLinkOptions _options;
|
||||
private volatile bool _secure;
|
||||
private readonly RpcRouter<LinkContext> _router;
|
||||
private readonly Dictionary<string, IStateHandler> _handlers;
|
||||
private readonly TimeProvider _time;
|
||||
@@ -153,6 +160,9 @@ public sealed class FleetLink
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
|
||||
public bool IsSecure => _secure && IsConnected;
|
||||
|
||||
private async Task<string> RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
@@ -160,7 +170,7 @@ public sealed class FleetLink
|
||||
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
||||
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
||||
await socket.ConnectAsync(builder.Uri, ct);
|
||||
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
|
||||
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
|
||||
|
||||
string? failure = await AuthenticateAsync(transport, ct);
|
||||
if (failure is not null)
|
||||
@@ -168,6 +178,8 @@ public sealed class FleetLink
|
||||
return failure;
|
||||
}
|
||||
|
||||
_secure = transport.Secure;
|
||||
|
||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task run = session.RunAsync(sessionCts.Token);
|
||||
@@ -211,7 +223,7 @@ public sealed class FleetLink
|
||||
}
|
||||
|
||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
|
||||
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
@@ -222,9 +234,44 @@ public sealed class FleetLink
|
||||
}
|
||||
|
||||
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
||||
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
|
||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
|
||||
|
||||
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
|
||||
EphemeralKey? ephemeral = null;
|
||||
AuthParams authParams;
|
||||
try
|
||||
{
|
||||
if (_options.ServerVerifier is { Required: true } verifier)
|
||||
{
|
||||
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|
||||
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
|
||||
{
|
||||
return "the server could not prove its identity (it is not the server your operator set authorizes)";
|
||||
}
|
||||
|
||||
ephemeral = new EphemeralKey();
|
||||
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
|
||||
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
|
||||
authParams = new AuthParams
|
||||
{
|
||||
NodeId = _options.NodeId.Value,
|
||||
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
||||
NodeEphemeral = nodeEphemeralText,
|
||||
};
|
||||
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
||||
}
|
||||
else
|
||||
{
|
||||
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
|
||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ephemeral?.Dispose();
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
@@ -259,7 +306,26 @@ public sealed class FleetLink
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
|
||||
private static bool TryDecodeBase64(string? text, out byte[] data)
|
||||
{
|
||||
data = [];
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
|
||||
{
|
||||
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
||||
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Link;
|
||||
|
||||
/// <summary>
|
||||
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
|
||||
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
|
||||
/// </summary>
|
||||
internal sealed class SecureTransport : IRpcTransport
|
||||
{
|
||||
private readonly IRpcTransport _inner;
|
||||
private LinkCipher? _cipher;
|
||||
private bool _allowPlaintext = true;
|
||||
|
||||
public SecureTransport(IRpcTransport inner)
|
||||
{
|
||||
_inner = inner;
|
||||
}
|
||||
|
||||
public bool Secure => _cipher is not null;
|
||||
|
||||
public void Enable(LinkCipher cipher) => _cipher = cipher;
|
||||
|
||||
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
|
||||
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
|
||||
|
||||
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
||||
{
|
||||
RpcFrame? frame = await _inner.ReceiveAsync(ct);
|
||||
if (frame is not { } received || _cipher is null)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
|
||||
{
|
||||
_allowPlaintext = false;
|
||||
return new RpcFrame((FrameKind)kind, data);
|
||||
}
|
||||
|
||||
if (_allowPlaintext && received.Kind == FrameKind.Text)
|
||||
{
|
||||
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
|
||||
}
|
||||
|
||||
throw new IOException("a frame on the secure link failed to authenticate");
|
||||
}
|
||||
|
||||
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cipher?.Dispose();
|
||||
await _inner.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,6 @@ public static class FleetMethodTable
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
|
||||
public static class FleetChannels
|
||||
{
|
||||
public const byte FirstProductChannel = 16;
|
||||
}
|
||||
|
||||
public sealed class ChallengeParams
|
||||
{
|
||||
[JsonPropertyName("nonce")]
|
||||
@@ -53,6 +47,14 @@ public sealed class ChallengeParams
|
||||
|
||||
[JsonPropertyName("expires_at_ms")]
|
||||
public long ExpiresAtMs { get; set; }
|
||||
|
||||
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
|
||||
[JsonPropertyName("server_eph")]
|
||||
public string? ServerEphemeral { get; set; }
|
||||
|
||||
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
|
||||
[JsonPropertyName("server_sig")]
|
||||
public string? ServerSignature { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthParams
|
||||
@@ -63,6 +65,10 @@ public sealed class AuthParams
|
||||
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
|
||||
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||
[JsonPropertyName("node_eph")]
|
||||
public string? NodeEphemeral { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthResult
|
||||
|
||||
@@ -33,7 +33,8 @@ public sealed record NewNode(
|
||||
string? NodeVersion,
|
||||
long NowMs,
|
||||
string Kind = "",
|
||||
byte[]? TlsPublicKey = null);
|
||||
byte[]? TlsPublicKey = null,
|
||||
string? AddedBy = null);
|
||||
|
||||
public sealed record JoinTokenRecord(
|
||||
string Id,
|
||||
@@ -46,6 +47,7 @@ public sealed record JoinTokenRecord(
|
||||
long ExpiresAtMs,
|
||||
long? RevokedAtMs,
|
||||
long CreatedAtMs,
|
||||
string Kind = "");
|
||||
string Kind = "",
|
||||
string? CreatedBy = null);
|
||||
|
||||
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
||||
@@ -6,6 +6,24 @@ namespace Fleet.Server;
|
||||
|
||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
||||
|
||||
/// <summary>
|
||||
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
|
||||
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
|
||||
/// </summary>
|
||||
public interface IServerIdentity
|
||||
{
|
||||
byte[] PublicKeySpki { get; }
|
||||
|
||||
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
|
||||
byte[] Sign(ReadOnlySpan<byte> payload);
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
||||
public interface IOperatorTrustSource
|
||||
{
|
||||
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
||||
public interface IEnrollmentHook
|
||||
{
|
||||
|
||||
@@ -37,6 +37,12 @@ public sealed class EnrollRequest
|
||||
public JsonElement? Meta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TrustRequest
|
||||
{
|
||||
[JsonPropertyName("join_token")]
|
||||
public string JoinToken { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class EnrollResponse
|
||||
{
|
||||
[JsonPropertyName("node_id")]
|
||||
@@ -69,5 +75,7 @@ public sealed class FleetProblem
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EnrollRequest))]
|
||||
[JsonSerializable(typeof(EnrollResponse))]
|
||||
[JsonSerializable(typeof(TrustRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
|
||||
[JsonSerializable(typeof(FleetProblem))]
|
||||
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
||||
@@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints
|
||||
.WithTags("agent")
|
||||
.WithSummary("Enroll a node with a join token")
|
||||
.DisableAntiforgery();
|
||||
// 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去
|
||||
app.MapPost(path + "/trust", HandleTrustAsync)
|
||||
.WithName("fleet_enroll_trust")
|
||||
.WithTags("agent")
|
||||
.WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls")
|
||||
.DisableAntiforgery();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleTrustAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
TrustRequest? request;
|
||||
try
|
||||
{
|
||||
request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
request = null;
|
||||
}
|
||||
|
||||
if (request is null || string.IsNullOrEmpty(request.JoinToken))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required");
|
||||
}
|
||||
|
||||
Result<TrustBundle> result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted);
|
||||
if (!result.TryGet(out TrustBundle? bundle, out FleetError? error))
|
||||
{
|
||||
return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
EnrollRequest? request;
|
||||
|
||||
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
|
||||
{
|
||||
private readonly IFleetStore _store;
|
||||
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
||||
private readonly IEnumerable<IOperatorTrustSource> _trust;
|
||||
private readonly FleetServerOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_hooks = hooks;
|
||||
_trust = trust;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
@@ -80,7 +82,8 @@ public sealed partial class FleetEnrollmentService
|
||||
request.NodeVersion,
|
||||
nowMs,
|
||||
token.Kind,
|
||||
tlsPublicKey);
|
||||
tlsPublicKey,
|
||||
token.CreatedBy);
|
||||
|
||||
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
||||
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
||||
@@ -101,6 +104,30 @@ public sealed partial class FleetEnrollmentService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
|
||||
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
|
||||
{
|
||||
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
|
||||
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||
}
|
||||
|
||||
if (token.ExpiresAtMs <= nowMs)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
||||
}
|
||||
|
||||
var bundle = new TrustBundle();
|
||||
foreach (IOperatorTrustSource source in _trust)
|
||||
{
|
||||
bundle.Chain.AddRange(await source.ChainAsync(ct));
|
||||
}
|
||||
|
||||
return Result<TrustBundle>.Ok(bundle);
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
IEnumerable<IStateProvider> stateProviders,
|
||||
IEnumerable<IInbox> inboxes,
|
||||
IEnumerable<INodeEventSink> sinks,
|
||||
FleetRouter router)
|
||||
FleetRouter router,
|
||||
IServerIdentity? serverIdentity = null)
|
||||
{
|
||||
ServerIdentity = serverIdentity;
|
||||
Store = store;
|
||||
Options = options;
|
||||
Time = time;
|
||||
@@ -38,6 +40,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
|
||||
internal IFleetStore Store { get; }
|
||||
|
||||
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||
internal IServerIdentity? ServerIdentity { get; }
|
||||
|
||||
internal FleetServerOptions Options { get; }
|
||||
|
||||
internal TimeProvider Time { get; }
|
||||
|
||||
@@ -109,7 +109,8 @@ public static class FleetServerExtensions
|
||||
provider.GetServices<IStateProvider>(),
|
||||
provider.GetServices<IInbox>(),
|
||||
provider.GetServices<INodeEventSink>(),
|
||||
provider.GetRequiredService<FleetRouter>()));
|
||||
provider.GetRequiredService<FleetRouter>(),
|
||||
provider.GetService<IServerIdentity>()));
|
||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
return builder;
|
||||
|
||||
@@ -14,9 +14,19 @@ internal sealed partial class NodeConnection
|
||||
{
|
||||
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
||||
await SendAsync(
|
||||
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
|
||||
ct);
|
||||
|
||||
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
|
||||
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
|
||||
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
|
||||
string? serverEphemeralText = null;
|
||||
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
|
||||
{
|
||||
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
|
||||
challenge.ServerEphemeral = serverEphemeralText;
|
||||
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
|
||||
}
|
||||
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
|
||||
|
||||
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
@@ -48,10 +58,21 @@ internal sealed partial class NodeConnection
|
||||
}
|
||||
|
||||
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
||||
|
||||
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
|
||||
byte[] nodeEphemeral = [];
|
||||
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
|
||||
bool signatureOk = node is not null
|
||||
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
|
||||
&& (secure
|
||||
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
|
||||
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
|
||||
if (signatureOk && secure)
|
||||
{
|
||||
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
|
||||
}
|
||||
|
||||
if (!signatureOk)
|
||||
{
|
||||
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
||||
|
||||
@@ -75,7 +75,15 @@ internal sealed partial class NodeConnection
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cipher is { } cipher)
|
||||
{
|
||||
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (WebSocketException)
|
||||
|
||||
@@ -30,6 +30,19 @@ internal sealed partial class NodeConnection
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
if (_cipher is { } cipher)
|
||||
{
|
||||
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
|
||||
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
|
||||
{
|
||||
_closeReason ??= "frame failed authentication";
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Frame(kind == 0, plain);
|
||||
}
|
||||
|
||||
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading.Channels;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
@@ -24,6 +25,7 @@ internal sealed partial class NodeConnection : IDisposable
|
||||
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private LinkCipher? _cipher;
|
||||
private long _nextRequestId;
|
||||
private long _lastPresenceTicks;
|
||||
private NodeRecord? _node;
|
||||
@@ -134,6 +136,7 @@ internal sealed partial class NodeConnection : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cipher?.Dispose();
|
||||
_lifetime.Dispose();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
|
||||
internal sealed class TestAuthenticator : IDisposable
|
||||
{
|
||||
public const string RpId = "panel.example.com";
|
||||
public const string Origin = "https://panel.example.com";
|
||||
|
||||
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
private uint _counter;
|
||||
|
||||
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
|
||||
|
||||
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
|
||||
{
|
||||
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
|
||||
_counter++;
|
||||
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
|
||||
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
|
||||
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
|
||||
}
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
}
|
||||
|
||||
public sealed class OperatorTrustTests : IDisposable
|
||||
{
|
||||
private readonly TestAuthenticator _alice = new();
|
||||
private readonly TestAuthenticator _bob = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alice.Dispose();
|
||||
_bob.Dispose();
|
||||
}
|
||||
|
||||
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
|
||||
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
|
||||
|
||||
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
|
||||
|
||||
[Fact]
|
||||
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
|
||||
{
|
||||
OperatorSet set = Genesis(_alice);
|
||||
SignedStatement genesis = SignedGenesis(set, _alice);
|
||||
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
||||
|
||||
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
|
||||
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
|
||||
{
|
||||
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
|
||||
string pin = OperatorTrust.Fingerprint(real.Statement);
|
||||
|
||||
using var attacker = new TestAuthenticator();
|
||||
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
|
||||
|
||||
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
|
||||
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
|
||||
{
|
||||
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
|
||||
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
||||
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
|
||||
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
|
||||
|
||||
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
|
||||
Assert.Equal(2, current?.Version);
|
||||
Assert.Equal(2, current?.Keys.Count);
|
||||
|
||||
// 新加的 bob 可以签下一版(上一版里有他)
|
||||
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
|
||||
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
|
||||
|
||||
// 版本号跳了、prev 不对、域名换了:都不行
|
||||
var skipped = second with { Version = 3 };
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
|
||||
var wrongPrev = second with { Previous = new string('a', 64) };
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
|
||||
var otherRp = second with { RpId = "evil.example" };
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
|
||||
{
|
||||
OperatorSet set = Genesis(_alice);
|
||||
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
|
||||
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
|
||||
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
|
||||
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
|
||||
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsing_IsStrict()
|
||||
{
|
||||
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
|
||||
OperatorSet set = Genesis(_alice);
|
||||
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
|
||||
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
|
||||
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
|
||||
public sealed class OwnerGuardTests : IDisposable
|
||||
{
|
||||
private readonly TestAuthenticator _alice = new();
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public OwnerGuardTests()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alice.Dispose();
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private void Pin(params string[] controllers)
|
||||
{
|
||||
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
|
||||
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
|
||||
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
|
||||
{
|
||||
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.NotNull(OwnerGuard.Problem(none, require: true));
|
||||
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANodePinnedToAnOwnerPasskey_Runs()
|
||||
{
|
||||
Pin(_alice.CredentialId);
|
||||
|
||||
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Null(OwnerGuard.Problem(pinned, require: true));
|
||||
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||
Assert.Equal([_alice.CredentialId], pinned.Controllers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
|
||||
{
|
||||
Pin("some-passkey-that-is-not-in-the-set");
|
||||
|
||||
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Empty(pinned.Controllers);
|
||||
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
|
||||
{
|
||||
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Null(OwnerGuard.Problem(none, require: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Security.Cryptography;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class SecureChannelTests
|
||||
{
|
||||
private const string Nonce = "n0nce-from-the-server";
|
||||
|
||||
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
|
||||
{
|
||||
using var node = new EphemeralKey();
|
||||
using var server = new EphemeralKey();
|
||||
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
|
||||
{
|
||||
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var eph = new EphemeralKey();
|
||||
using var otherEph = new EphemeralKey();
|
||||
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
|
||||
|
||||
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
|
||||
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
|
||||
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
|
||||
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
|
||||
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
|
||||
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair();
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
|
||||
byte[] toServer = n.Seal(0, "hello server"u8);
|
||||
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
|
||||
Assert.Equal(0, kind);
|
||||
Assert.Equal("hello server"u8.ToArray(), data);
|
||||
|
||||
byte[] big = RandomNumberGenerator.GetBytes(300_000);
|
||||
byte[] toNode = s.Seal(1, big);
|
||||
Assert.True(n.TryOpen(toNode, out kind, out data));
|
||||
Assert.Equal(1, kind);
|
||||
Assert.Equal(big, data);
|
||||
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair();
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
|
||||
byte[] first = n.Seal(0, "one"u8);
|
||||
byte[] second = n.Seal(0, "two"u8);
|
||||
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
|
||||
Assert.True(s.TryOpen(first, out _, out _));
|
||||
Assert.False(s.TryOpen(first, out _, out _)); // 重放
|
||||
Assert.True(s.TryOpen(second, out _, out _));
|
||||
|
||||
byte[] tampered = n.Seal(0, "three"u8);
|
||||
tampered[12] ^= 0x01;
|
||||
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
|
||||
|
||||
byte[] fromNode = n.Seal(0, "four"u8);
|
||||
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
|
||||
using LinkCipher on = otherNode;
|
||||
using LinkCipher os = otherServer;
|
||||
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
|
||||
|
||||
byte[] fromServer = s.Seal(0, "reflected"u8);
|
||||
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
|
||||
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADifferentNonce_GivesDifferentKeys()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
|
||||
{
|
||||
using var alice = new TestAuthenticator();
|
||||
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
|
||||
string text = OperatorTrust.Encode(set);
|
||||
|
||||
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
|
||||
Assert.Equal(spki, Assert.Single(parsed.Servers!));
|
||||
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
|
||||
|
||||
SignedStatement genesis = alice.Sign(text);
|
||||
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
|
||||
Assert.Equal(spki, Assert.Single(verified.Servers!));
|
||||
|
||||
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
|
||||
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
|
||||
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ string? name = Arg("--name");
|
||||
string? directory = Arg("--identity-dir");
|
||||
if (server is null || join is null || name is null || directory is null)
|
||||
{
|
||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
|
||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -30,14 +30,23 @@ if (Arg("--meta") is { } metaText)
|
||||
}
|
||||
}
|
||||
|
||||
string? genesis = Arg("--genesis");
|
||||
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (genesis is not null && controllers.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
|
||||
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
||||
{
|
||||
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
|
||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)"));
|
||||
return 0;
|
||||
+1800
-90
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
|
||||
|
||||
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
|
||||
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -30,7 +30,7 @@ dotnet build probe/src/MoShiro.Probe -c Release
|
||||
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
## 配置与运行
|
||||
|
||||
@@ -54,6 +54,6 @@ moshiro-probe --config /etc/moshiro/probe.json
|
||||
|
||||
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
|
||||
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
|
||||
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
|
||||
- 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。
|
||||
|
||||
Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
@@ -16,11 +16,13 @@ public sealed class AgentDirectory
|
||||
private readonly object _gate = new();
|
||||
private readonly ConfigStore _store;
|
||||
private readonly INodeLog _log;
|
||||
private readonly TrustAnchor _trust;
|
||||
private List<KnownAgent> _agents = [];
|
||||
private long _version;
|
||||
|
||||
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log)
|
||||
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
|
||||
{
|
||||
_trust = trust ?? TrustAnchor.None;
|
||||
_store = store;
|
||||
_log = log;
|
||||
string cache = Path.Combine(configDirectory, FileName);
|
||||
@@ -68,6 +70,7 @@ public sealed class AgentDirectory
|
||||
public void Apply(ProbeAgentList result)
|
||||
{
|
||||
var next = new List<KnownAgent>();
|
||||
_trust.Update(result.TrustChain);
|
||||
foreach (AgentEndpoint agent in result.Agents)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||
|
||||
@@ -22,25 +22,31 @@ public sealed class ProbeApp : IDisposable
|
||||
private readonly SemaphoreSlim _changed = new(0);
|
||||
private readonly INodeLog _log;
|
||||
private readonly FleetLink _link;
|
||||
private readonly ReportingSection _reporting;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
|
||||
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||
{
|
||||
_log = log;
|
||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
|
||||
_time = time;
|
||||
_reporting = file.Reporting;
|
||||
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
|
||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
|
||||
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
|
||||
directory.Changed += () => _changed.Release();
|
||||
|
||||
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
|
||||
RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log);
|
||||
var options = new FleetLinkOptions
|
||||
{
|
||||
Server = new Uri(file.Fleet.Server),
|
||||
Identity = identity.Identity,
|
||||
ServerVerifier = trust,
|
||||
NodeId = identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base],
|
||||
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update?.Status(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||
};
|
||||
var list = new StateHandler<ProbeAgentList>(
|
||||
ProbeAgentList.StateType,
|
||||
@@ -53,12 +59,12 @@ public sealed class ProbeApp : IDisposable
|
||||
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
|
||||
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||
{
|
||||
update.WriteOffer(offer);
|
||||
update?.Offer(offer);
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
|
||||
{
|
||||
update.RequestApply();
|
||||
update?.RequestApply();
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
|
||||
@@ -103,6 +109,7 @@ public sealed class ProbeApp : IDisposable
|
||||
}
|
||||
|
||||
Task link = _link.RunAsync(ct);
|
||||
Task host = ReportHostAsync(ct);
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -118,7 +125,41 @@ public sealed class ProbeApp : IDisposable
|
||||
finally
|
||||
{
|
||||
await ReconcileAsync([], CancellationToken.None);
|
||||
await Task.WhenAny(link, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
|
||||
await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。</summary>
|
||||
private async Task ReportHostAsync(CancellationToken ct)
|
||||
{
|
||||
if (!_reporting.HostStats)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sampler = new HostSampler(_time);
|
||||
sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_reporting.HostIntervalSeconds, 1, 60)), _time);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (_link.CurrentSession is { } session)
|
||||
{
|
||||
try
|
||||
{
|
||||
await session.NotifyAsync(MoShiroMethods.Host, sampler.Sample(), MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_log.Warn($"could not report host status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 停止
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe;
|
||||
@@ -18,6 +19,10 @@ public sealed class ProbeFile
|
||||
|
||||
[JsonPropertyName("limits")]
|
||||
public ProbeLimits Limits { get; set; } = new();
|
||||
|
||||
/// <summary>上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 <c>host_stats: false</c> 就一个字也不上报。</summary>
|
||||
[JsonPropertyName("reporting")]
|
||||
public ReportingSection Reporting { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ProbeFleetSection
|
||||
@@ -29,6 +34,10 @@ public sealed class ProbeFleetSection
|
||||
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
|
||||
[JsonPropertyName("identity_dir")]
|
||||
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
|
||||
[JsonPropertyName("require_owner")]
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class KnownAgent
|
||||
|
||||
@@ -38,6 +38,12 @@ try
|
||||
identity.Dispose();
|
||||
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
|
||||
}
|
||||
|
||||
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
|
||||
{
|
||||
identity.Dispose();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
|
||||
|
||||
+27
-7
@@ -1,13 +1,13 @@
|
||||
using System.Globalization;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||||
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||||
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
|
||||
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
|
||||
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
|
||||
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
|
||||
/// </summary>
|
||||
public sealed class HostSampler
|
||||
{
|
||||
@@ -18,6 +18,7 @@ public sealed class HostSampler
|
||||
private readonly Func<(long Total, long Used)?> _disk;
|
||||
private readonly string _procRoot;
|
||||
private readonly string _sysRoot;
|
||||
private readonly bool _native;
|
||||
private (long Total, long Idle)? _previousCpu;
|
||||
private (long At, long Rx, long Tx)? _previousNet;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class HostSampler
|
||||
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
|
||||
{
|
||||
_time = time;
|
||||
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
|
||||
_read = read ?? ReadProc;
|
||||
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
|
||||
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
|
||||
@@ -37,7 +39,8 @@ public sealed class HostSampler
|
||||
long now = _time.GetTimestamp();
|
||||
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
|
||||
|
||||
if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
|
||||
(long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null;
|
||||
if (cpuNow is { } cpu)
|
||||
{
|
||||
if (_previousCpu is { } before && cpu.Total > before.Total)
|
||||
{
|
||||
@@ -48,7 +51,8 @@ public sealed class HostSampler
|
||||
_previousCpu = cpu;
|
||||
}
|
||||
|
||||
if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
|
||||
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null;
|
||||
if (memoryNow is { } memory)
|
||||
{
|
||||
snapshot.MemTotal = memory.Total;
|
||||
snapshot.MemUsed = memory.Used;
|
||||
@@ -60,11 +64,21 @@ public sealed class HostSampler
|
||||
snapshot.DiskUsed = disk.Used;
|
||||
}
|
||||
|
||||
if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
|
||||
(long Rx, long Tx, long? LinkMbps)? netNow = null;
|
||||
if (_native)
|
||||
{
|
||||
netNow = WindowsNetwork();
|
||||
}
|
||||
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
|
||||
{
|
||||
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
|
||||
}
|
||||
|
||||
if (netNow is { } net)
|
||||
{
|
||||
snapshot.NetRxTotal = net.Rx;
|
||||
snapshot.NetTxTotal = net.Tx;
|
||||
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
|
||||
snapshot.NetLinkMbps = net.LinkMbps;
|
||||
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
|
||||
{
|
||||
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
|
||||
@@ -83,6 +97,12 @@ public sealed class HostSampler
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
|
||||
|
||||
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
|
||||
|
||||
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
|
||||
|
||||
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
|
||||
public static (long Total, long Idle)? ParseCpu(string stat)
|
||||
{
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):Agent 和 Probe 共用。</summary>
|
||||
public sealed class ReportingSection
|
||||
{
|
||||
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
|
||||
[JsonPropertyName("host_stats")]
|
||||
public bool HostStats { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("host_interval_s")]
|
||||
public int HostIntervalSeconds { get; set; } = 2;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 上读主机资源占用(<see cref="HostSampler"/> 在 Linux 之外的实现):CPU 时间和物理内存用两个只读的系统调用
|
||||
/// (<c>GetSystemTimes</c>、<c>GlobalMemoryStatusEx</c>),网卡流量和链路速率用 .NET 自带的 <see cref="NetworkInterface"/>。
|
||||
/// 和 Linux 一样只有汇总的数字:不读网卡名、MAC、IP,也不启动任何进程。
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static partial class WindowsHost
|
||||
{
|
||||
private static readonly string[] s_virtualWords = ["Virtual", "vEthernet", "Hyper-V", "VMware", "VirtualBox", "TAP-", "Wintun", "WireGuard", "Npcap", "Bluetooth", "Loopback", "isatap", "Teredo"];
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct FileTime
|
||||
{
|
||||
public uint Low;
|
||||
public uint High;
|
||||
|
||||
public readonly long Value => (long)(((ulong)High << 32) | Low);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MemoryStatus
|
||||
{
|
||||
public uint Length;
|
||||
public uint MemoryLoad;
|
||||
public ulong TotalPhys;
|
||||
public ulong AvailPhys;
|
||||
public ulong TotalPageFile;
|
||||
public ulong AvailPageFile;
|
||||
public ulong TotalVirtual;
|
||||
public ulong AvailVirtual;
|
||||
public ulong AvailExtendedVirtual;
|
||||
}
|
||||
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user);
|
||||
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool GlobalMemoryStatusEx(ref MemoryStatus status);
|
||||
|
||||
/// <summary>系统启动以来的 CPU 总时间和空闲时间(和 Linux 的 <c>/proc/stat</c> 第一行同一个意思:两次采样相减得到占用)。</summary>
|
||||
public static (long Total, long Idle)? Cpu()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 内核时间里已经包含了空闲时间
|
||||
return (kernel.Value + user.Value, idle.Value);
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>物理内存的总量和已用(总量减去可用)。</summary>
|
||||
public static (long Total, long Used)? Memory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = new MemoryStatus { Length = (uint)Marshal.SizeOf<MemoryStatus>() };
|
||||
if (!GlobalMemoryStatusEx(ref status) || status.TotalPhys == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((long)status.TotalPhys, (long)(status.TotalPhys - Math.Min(status.AvailPhys, status.TotalPhys)));
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>所有物理网卡(在用的,排除回环、隧道、虚拟网卡)的累计收发字节,以及链路速率之和(Mbit/s;有一块读不到速率就是 null)。</summary>
|
||||
public static (long Rx, long Tx, long? LinkMbps)? Network()
|
||||
{
|
||||
try
|
||||
{
|
||||
long rx = 0;
|
||||
long tx = 0;
|
||||
long mbps = 0;
|
||||
bool speedKnown = true;
|
||||
bool any = false;
|
||||
foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel
|
||||
|| s_virtualWords.Any(w => nic.Description.Contains(w, StringComparison.OrdinalIgnoreCase) || nic.Name.Contains(w, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IPInterfaceStatistics stats = nic.GetIPStatistics();
|
||||
rx += stats.BytesReceived;
|
||||
tx += stats.BytesSent;
|
||||
any = true;
|
||||
if (nic.Speed > 0)
|
||||
{
|
||||
mbps += nic.Speed / 1_000_000;
|
||||
}
|
||||
else
|
||||
{
|
||||
speedKnown = false;
|
||||
}
|
||||
}
|
||||
|
||||
return any ? (rx, tx, speedKnown && mbps > 0 ? mbps : null) : null;
|
||||
}
|
||||
catch (NetworkInformationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的「更新」:面板建议、请求,节点<b>只负责有序退出</b>,换新版本由外面的服务管理器完成——
|
||||
/// 例如 Podman 的 Quadlet 单元写了 <c>Pull=always</c>,每次启动都先从镜像仓库拉最新的镜像,所以退出再被拉起就是更新。
|
||||
/// 节点自己不下载、不安装、不执行任何东西,所以不需要验证什么:最多让面板(管理员)请求一次重启,和定时重启是同一种能力。
|
||||
/// <list type="bullet">
|
||||
/// <item>只有设了环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用(Quadlet 模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新,所以没有启用就不报告更新状态。</item>
|
||||
/// <item>策略由环境变量 <c>MOSHIRO_UPDATE_POLICY</c> 决定:<c>auto</c>(默认)发现新版本就自己退出重启,<c>manual</c> 等管理端请求。自动更新有保护:
|
||||
/// 进程至少运行 <see cref="MinimumUptime"/> 才会自动退出,新镜像没拉到(标签没变、仓库还没更新)最多每个这么久重启一次,不会陷入重启循环。</item>
|
||||
/// <item>面板转来的发布清单(面板登记时已验过 CI 签名)只用来显示「有没有新版本」:组件对、比这个程序新(seq 晚于构建时间)、tag 和提交不同。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class RestartUpdater
|
||||
{
|
||||
public const string EnvironmentVariable = "MOSHIRO_UPDATE";
|
||||
public const string PolicyVariable = "MOSHIRO_UPDATE_POLICY";
|
||||
|
||||
/// <summary>自动更新之前,进程至少要运行这么久。</summary>
|
||||
public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10);
|
||||
|
||||
private const int MaxManifestBytes = 4096;
|
||||
|
||||
private readonly string _component;
|
||||
private readonly Action<string> _requestRestart;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
private readonly string _tag;
|
||||
private readonly string _commit;
|
||||
private readonly long _builtAt;
|
||||
private readonly object _gate = new();
|
||||
private readonly bool _auto;
|
||||
private int _autoScheduled;
|
||||
private readonly long _startedAt;
|
||||
private UpdateStatus _status;
|
||||
|
||||
/// <param name="tag">这个程序的构建标签、提交和构建时间(秒):默认取 <see cref="BuildInfo"/>,测试可以传入。</param>
|
||||
public RestartUpdater(string component, Action<string> requestRestart, TimeProvider time, INodeLog log, string? tag = null, string? commit = null, long? builtAtEpoch = null, bool auto = false)
|
||||
{
|
||||
_auto = auto;
|
||||
_startedAt = time.GetTimestamp();
|
||||
_component = component;
|
||||
_requestRestart = requestRestart;
|
||||
_time = time;
|
||||
_log = log;
|
||||
_tag = tag ?? BuildInfo.Tag;
|
||||
_commit = commit ?? BuildInfo.Commit;
|
||||
_builtAt = builtAtEpoch ?? (DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) ? built.ToUnixTimeSeconds() : 0);
|
||||
_status = new UpdateStatus { State = "up_to_date", At = Now() };
|
||||
}
|
||||
|
||||
/// <summary>环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用;否则返回 null,节点不报告更新状态。</summary>
|
||||
public static RestartUpdater? FromEnvironment(string component, Action<string> requestRestart, TimeProvider time, INodeLog log) =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(EnvironmentVariable), "restart", StringComparison.Ordinal)
|
||||
? new RestartUpdater(component, requestRestart, time, log, auto: !string.Equals(Environment.GetEnvironmentVariable(PolicyVariable), "manual", StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
|
||||
/// <summary>面板转来的发布清单:只用来判断有没有新版本。</summary>
|
||||
public void Offer(UpdateOfferParams offer)
|
||||
{
|
||||
if (offer.Manifest.Length is 0 or > MaxManifestBytes)
|
||||
{
|
||||
_log.Warn("ignoring an update offer of an unexpected size");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(offer.Manifest);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object || Text(root, "component") != _component || !root.TryGetProperty("seq", out JsonElement seqValue) || !seqValue.TryGetInt64(out long seq))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string tag = Text(root, "tag");
|
||||
string commit = Text(root, "commit");
|
||||
bool same = (_tag.Length > 0 && tag == _tag) || (_commit.Length > 0 && commit.Length > 0 && commit == _commit);
|
||||
bool available;
|
||||
lock (_gate)
|
||||
{
|
||||
_status = same || seq <= _builtAt
|
||||
? new UpdateStatus { State = "up_to_date", At = Now() }
|
||||
: new UpdateStatus
|
||||
{
|
||||
State = "available",
|
||||
Available = new UpdateAvailable { Tag = tag, Version = Text(root, "version"), Digest = Text(root, "digest"), Seq = seq },
|
||||
At = Now(),
|
||||
};
|
||||
available = _status.State == "available";
|
||||
}
|
||||
|
||||
if (available && _auto && Interlocked.Exchange(ref _autoScheduled, 1) == 0)
|
||||
{
|
||||
_ = ApplyAfterMinimumUptimeAsync();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_log.Warn("ignoring an update offer that is not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>自动更新:等进程运行够久再退出(刚启动就发现新版本,通常是重启之后标签还没变,立刻再退出会变成重启循环)。</summary>
|
||||
private async Task ApplyAfterMinimumUptimeAsync()
|
||||
{
|
||||
TimeSpan wait = MinimumUptime - _time.GetElapsedTime(_startedAt);
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
_log.Info("update available, restarting automatically later", ("in", wait.ToString("c", CultureInfo.InvariantCulture)));
|
||||
await Task.Delay(wait, _time);
|
||||
}
|
||||
|
||||
RequestApply();
|
||||
}
|
||||
|
||||
/// <summary>面板请求更新:有新版本才退出(由外面的服务管理器拉起并拉取最新镜像)。</summary>
|
||||
public bool RequestApply()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_status.State != "available")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_status = new UpdateStatus { State = "applying", Available = _status.Available, At = Now() };
|
||||
}
|
||||
|
||||
_log.Info("update requested, restarting so the supervisor pulls the latest image");
|
||||
_requestRestart("update");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>随心跳上报的状态。</summary>
|
||||
public UpdateStatus Status()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return new UpdateStatus { State = _status.State, Current = _status.Current, Available = _status.Available, Error = _status.Error, At = _status.At };
|
||||
}
|
||||
}
|
||||
|
||||
private long Now() => _time.GetUtcNow().ToUnixTimeSeconds();
|
||||
|
||||
private static string Text(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 节点进程和宿主机更新器之间的文件交换(容器里 <c>/etc/moshiro</c>,也就是数据目录):
|
||||
/// 节点把面板发来的清单和签名写进 <c>update/</c>,把「更新」请求写成 <c>update/apply.request</c>;更新器验签并更新,状态由宿主更新器写在只读挂载的 <c>update-status/status.json</c>。
|
||||
/// 节点进程不执行任何命令,也不信任清单的内容(验签在宿主机上)。没有安装更新器时目录写不了或没人读,调用只是返回 false。
|
||||
/// </summary>
|
||||
public sealed class UpdateBridge
|
||||
{
|
||||
private const int MaxManifestBytes = 4096;
|
||||
private const int MaxSignatureBytes = 512;
|
||||
private const int MaxStatusBytes = 4096;
|
||||
|
||||
private readonly string _updateDirectory;
|
||||
private readonly string _statusFile;
|
||||
private readonly Action<string, byte[]> _write;
|
||||
private readonly INodeLog _log;
|
||||
|
||||
/// <param name="write">写数据目录里的文件(相对路径,原子替换):由节点传入 <c>ConfigStore.Write</c>——常驻进程里只有它可以写文件。</param>
|
||||
public UpdateBridge(string dataDirectory, Action<string, byte[]> write, INodeLog log)
|
||||
{
|
||||
_updateDirectory = Path.Combine(dataDirectory, "update");
|
||||
_statusFile = Path.Combine(dataDirectory, "update-status", "status.json");
|
||||
_write = write;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>放下面板发来的清单和签名(和已有的一样就不动,免得每次重连都惊动更新器)。先写清单、后写签名:更新器监视的是签名文件。</summary>
|
||||
public bool WriteOffer(UpdateOfferParams offer)
|
||||
{
|
||||
byte[] manifest = Encoding.UTF8.GetBytes(offer.Manifest);
|
||||
byte[] signature = Encoding.UTF8.GetBytes(offer.Signature);
|
||||
if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes)
|
||||
{
|
||||
_log.Warn("ignoring an update offer of an unexpected size");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string manifestPath = Path.Combine(_updateDirectory, "offer.manifest");
|
||||
string signaturePath = Path.Combine(_updateDirectory, "offer.sig");
|
||||
if (Same(manifestPath, manifest) && Same(signaturePath, signature))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_write("update/offer.manifest", manifest);
|
||||
_write("update/offer.sig", signature);
|
||||
_log.Info("update offer received");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not store the update offer (is the updater installed?)", ("err", ex.Message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>请求更新:写一个空的 <c>apply.request</c>(更新器验签通过、清单更新才会真的动手)。</summary>
|
||||
public bool RequestApply()
|
||||
{
|
||||
try
|
||||
{
|
||||
_write("update/apply.request", []);
|
||||
_log.Info("update requested");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not request the update (is the updater installed?)", ("err", ex.Message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>更新器的状态;没有安装更新器、文件不存在或内容不对时返回 null。</summary>
|
||||
public UpdateStatus? ReadStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_statusFile) || new FileInfo(_statusFile).Length > MaxStatusBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(File.ReadAllBytes(_statusFile), MoShiroJsonContext.Default.UpdateStatus);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ namespace MoShiro.Protocol;
|
||||
public static class ProtocolVersion
|
||||
{
|
||||
public const int Container = 1;
|
||||
public const int Wire = 1;
|
||||
}
|
||||
|
||||
public static class ProtocolLimits
|
||||
@@ -39,5 +38,4 @@ public static class MoShiroCapabilities
|
||||
{
|
||||
public const string Base = "moshiro.v1";
|
||||
public const string KindsPrefix = "moshiro.kinds:";
|
||||
public const string BlocksPrefix = "moshiro.blocks:";
|
||||
}
|
||||
@@ -34,10 +34,10 @@ public static class MoShiroMethodTable
|
||||
MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server),
|
||||
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
|
||||
|
||||
// 节点面:Agent → Backend
|
||||
// 节点面:节点 → Backend
|
||||
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
|
||||
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent),
|
||||
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node, NodeRoles.Agent),
|
||||
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总
|
||||
|
||||
// 对等面:Probe(拨号方)→ Agent(监听方)
|
||||
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
|
||||
|
||||
@@ -164,6 +164,9 @@ public sealed class ProbeAgentList
|
||||
|
||||
[JsonPropertyName("agents")]
|
||||
public List<AgentEndpoint> Agents { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("trust_chain")]
|
||||
public List<SignedStatementDto>? TrustChain { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
|
||||
@@ -303,6 +306,10 @@ public sealed class ProbeHeartbeatExtra
|
||||
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
||||
public sealed class ProbeHelloMeta
|
||||
{
|
||||
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||
[JsonPropertyName("owners")]
|
||||
public List<string>? Owners { get; set; }
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
@@ -313,6 +320,10 @@ public sealed class ProbeHelloMeta
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
{
|
||||
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||
[JsonPropertyName("owners")]
|
||||
public List<string>? Owners { get; set; }
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ public sealed class TaskSet
|
||||
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
|
||||
[JsonPropertyName("peers")]
|
||||
public List<PeerSpec> Peers { get; set; } = [];
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。</summary>
|
||||
[JsonPropertyName("trust_chain")]
|
||||
public List<SignedStatementDto>? TrustChain { get; set; }
|
||||
}
|
||||
|
||||
public static class TaskSchedule
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement.Tests;
|
||||
|
||||
public sealed class RestartUpdaterTests
|
||||
{
|
||||
private readonly List<string> _restarts = [];
|
||||
|
||||
private RestartUpdater Create(string tag = "dev-aaa", string commit = "aaa", long builtAt = 1_000) =>
|
||||
new("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance, tag, commit, builtAt);
|
||||
|
||||
private static UpdateOfferParams Offer(string tag, long seq, string component = "agent", string commit = "bbb") =>
|
||||
new() { Manifest = $"{{\"component\":\"{component}\",\"tag\":\"{tag}\",\"version\":\"0.4.0\",\"commit\":\"{commit}\",\"digest\":\"sha256:{new string('a', 64)}\",\"seq\":{seq}}}", Signature = "ignored" };
|
||||
|
||||
[Fact]
|
||||
public void ANewerRelease_IsAvailable_AndOnlyARequestRestartsTheNode()
|
||||
{
|
||||
RestartUpdater updater = Create();
|
||||
updater.Offer(Offer("dev-bbb", 2_000));
|
||||
UpdateStatus status = updater.Status();
|
||||
Assert.Equal("available", status.State);
|
||||
Assert.Equal("dev-bbb", status.Available!.Tag);
|
||||
Assert.Equal(2_000, status.Available.Seq);
|
||||
Assert.Empty(_restarts);
|
||||
|
||||
Assert.True(updater.RequestApply());
|
||||
Assert.Equal(["update"], _restarts);
|
||||
Assert.Equal("applying", updater.Status().State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheReleaseThatIsAlreadyRunning_IsUpToDate_ByTagOrByCommit()
|
||||
{
|
||||
RestartUpdater updater = Create();
|
||||
updater.Offer(Offer("dev-aaa", 2_000));
|
||||
Assert.Equal("up_to_date", updater.Status().State);
|
||||
updater.Offer(Offer("dev-other-tag", 2_000, commit: "aaa"));
|
||||
Assert.Equal("up_to_date", updater.Status().State);
|
||||
Assert.False(updater.RequestApply());
|
||||
Assert.Empty(_restarts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOfferOlderThanThisBuild_OrForAnotherComponent_IsNotAnUpdate()
|
||||
{
|
||||
RestartUpdater updater = Create(builtAt: 5_000);
|
||||
updater.Offer(Offer("dev-bbb", 2_000));
|
||||
Assert.Equal("up_to_date", updater.Status().State);
|
||||
updater.Offer(Offer("dev-ccc", 9_000, component: "probe"));
|
||||
Assert.Equal("up_to_date", updater.Status().State);
|
||||
Assert.False(updater.RequestApply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GarbageOffers_AreIgnored()
|
||||
{
|
||||
RestartUpdater updater = Create();
|
||||
updater.Offer(new UpdateOfferParams { Manifest = "not json", Signature = "x" });
|
||||
updater.Offer(new UpdateOfferParams { Manifest = new string('x', 5000), Signature = "x" });
|
||||
updater.Offer(new UpdateOfferParams { Manifest = "{\"component\":\"agent\"}", Signature = "x" });
|
||||
Assert.Equal("up_to_date", updater.Status().State);
|
||||
Assert.False(updater.RequestApply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAutoPolicy_RestartsByItself_ButNotBeforeTheMinimumUptime()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000));
|
||||
var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: true);
|
||||
updater.Offer(Offer("dev-bbb", 2_000));
|
||||
updater.Offer(Offer("dev-bbb", 2_000)); // 重连时面板会再发一次:不能排两个
|
||||
Assert.Empty(_restarts);
|
||||
time.Advance(TimeSpan.FromMinutes(9));
|
||||
await Task.Delay(50);
|
||||
Assert.Empty(_restarts);
|
||||
time.Advance(TimeSpan.FromMinutes(2));
|
||||
await WaitAsync(() => _restarts.Count == 1);
|
||||
Assert.Equal("applying", updater.Status().State);
|
||||
await Task.Delay(50);
|
||||
Assert.Single(_restarts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheManualPolicy_NeverRestartsByItself()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000));
|
||||
var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: false);
|
||||
updater.Offer(Offer("dev-bbb", 2_000));
|
||||
time.Advance(TimeSpan.FromHours(1));
|
||||
await Task.Delay(50);
|
||||
Assert.Empty(_restarts);
|
||||
Assert.Equal("available", updater.Status().State);
|
||||
}
|
||||
|
||||
private static async Task WaitAsync(Func<bool> condition)
|
||||
{
|
||||
for (int i = 0; i < 100 && !condition(); i++)
|
||||
{
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
Assert.True(condition());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutTheEnvironmentVariable_ThereIsNoUpdater()
|
||||
{
|
||||
Assert.Null(Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable));
|
||||
Assert.Null(RestartUpdater.FromEnvironment("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
@@ -17,50 +18,67 @@ public static class AgentEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/agents", ListAsync)
|
||||
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403);
|
||||
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapGet("/agents/{id}", GetAsync)
|
||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPatch("/agents/{id}", PatchAsync)
|
||||
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
|
||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
|
||||
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
|
||||
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
|
||||
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
|
||||
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
.Returns<AgentDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
group.MapDelete("/agents/{id}", DeleteAsync)
|
||||
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
|
||||
group.MapGet("/join-tokens", ListTokensAsync)
|
||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
|
||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPost("/join-tokens", CreateTokenAsync)
|
||||
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
|
||||
group.MapGet("/probes", ListProbesAsync)
|
||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
|
||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapGet("/probes/{id}", GetProbeAsync)
|
||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
|
||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
|
||||
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
|
||||
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
.Returns<ProbeDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
||||
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
|
||||
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
|
||||
@@ -112,11 +130,23 @@ public static class AgentEndpoints
|
||||
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options)
|
||||
{
|
||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||
|
||||
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
|
||||
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
|
||||
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
|
||||
Result<string[]> owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers);
|
||||
if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError))
|
||||
{
|
||||
return Problems.From(http, ownerError);
|
||||
}
|
||||
|
||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole), InstallScripts.QuadletSha256(request.Role ?? JoinTokenService.AgentRole));
|
||||
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
|
||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole), baseUrl, http.RequestAborted);
|
||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
@@ -124,7 +154,7 @@ public static class AgentEndpoints
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows), new QuadletInstallDto(created.Quadlet.Env, created.Quadlet.Url, created.Quadlet.Sha256, created.Quadlet.Command)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
|
||||
@@ -149,15 +179,32 @@ public static class AgentEndpoints
|
||||
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
|
||||
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
|
||||
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct)
|
||||
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
if (request.Name is null)
|
||||
if (request.Name is null && request.Isp is null)
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("name", "is required"));
|
||||
return Problems.From(http, ValidationError.Single("name", "name or isp is required"));
|
||||
}
|
||||
|
||||
Result<ProbeView> result = await probes.RenameAsync(id, request.Name, ct);
|
||||
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
|
||||
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
|
||||
}
|
||||
|
||||
Result<ProbeView> result = request.Name is null
|
||||
? (await probes.GetAsync(id, ct) is { } existing ? Result<ProbeView>.Ok(existing) : Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found"))
|
||||
: await probes.RenameAsync(id, request.Name, ct);
|
||||
if (!result.TryGet(out ProbeView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
if (request.Isp is not null)
|
||||
{
|
||||
await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct);
|
||||
}
|
||||
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
|
||||
public static class AuditEndpoints
|
||||
{
|
||||
private const string Tag = "admin-audit";
|
||||
|
||||
/// <summary>handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。</summary>
|
||||
public const string DetailItem = "moshiro.audit.detail";
|
||||
|
||||
public static void Map(RouteGroupBuilder guarded) =>
|
||||
guarded.MapGet("/audit", ListAsync)
|
||||
.Api("audit_list", "Audit log of admin actions, newest first", Tag).Returns<PageDto<AuditDto>>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.AuditRead);
|
||||
|
||||
public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail;
|
||||
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AuditService audit, string? actor, string? action, int? limit, string? cursor)
|
||||
{
|
||||
long? before = null;
|
||||
if (cursor is not null)
|
||||
{
|
||||
if (!long.TryParse(cursor, out long parsed))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
|
||||
}
|
||||
|
||||
before = parsed;
|
||||
}
|
||||
|
||||
int size = Math.Clamp(limit ?? 50, 1, 500);
|
||||
IReadOnlyList<AuditRecord> rows = await audit.ListAsync(new AuditQuery(before, size + 1, string.IsNullOrEmpty(actor) ? null : actor, string.IsNullOrEmpty(action) ? null : action), http.RequestAborted);
|
||||
bool more = rows.Count > size;
|
||||
IReadOnlyList<AuditRecord> page = [.. rows.Take(size)];
|
||||
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
|
||||
return TypedResults.Json(new PageDto<AuditDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto);
|
||||
}
|
||||
}
|
||||
@@ -16,36 +16,104 @@ public static class AuthEndpoints
|
||||
public static void MapLogin(RouteGroupBuilder open)
|
||||
{
|
||||
open.MapPost("/auth/login", LoginAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||
open.MapGet("/auth/setup", SetupStatusAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("setup_status", "Whether the panel has no users yet and still has to be claimed", Tag)
|
||||
.Returns<SetupStatusDto>();
|
||||
open.MapPost("/auth/setup", SetupAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("setup_complete", "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)", Tag)
|
||||
.Returns<LoginResponse>(StatusCodes.Status201Created).ProducesProblems(400, 401, 409, 429);
|
||||
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
|
||||
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(429);
|
||||
open.MapPost("/auth/passkey/login", PasskeyLoginAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_passkey_login", "Sign in with a passkey assertion; same response as the password sign-in", Tag)
|
||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||
}
|
||||
|
||||
public static void MapSession(RouteGroupBuilder guarded)
|
||||
{
|
||||
guarded.MapPost("/auth/logout", LogoutAsync)
|
||||
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/passkeys", ListPasskeysAsync)
|
||||
.Api("auth_passkeys_list", "The signed-in user's passkeys", Tag)
|
||||
.Returns<PageDto<PasskeyDto>>().ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapPost("/auth/passkeys/options", PasskeyCreationOptionsAsync)
|
||||
.Api("auth_passkeys_options", "A challenge for registering a new passkey (the user must be signed in)", Tag)
|
||||
.Returns<PasskeyCreationOptionsDto>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapPost("/auth/passkeys", RegisterPasskeyAsync)
|
||||
.Api("auth_passkeys_register", "Register a passkey from a WebAuthn attestation (ES256, user verification required)", Tag)
|
||||
.Returns<PasskeyDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapDelete("/auth/passkeys/{id}", DeletePasskeyAsync)
|
||||
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/me", Me)
|
||||
.Api("auth_me", "The current identity", Tag)
|
||||
.Returns<MeDto>().ProducesProblems(401);
|
||||
.Returns<MeDto>().ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
|
||||
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
string source = http.ClientAddress();
|
||||
if (!limiter.TryAcquire(source, out int retryAfter))
|
||||
{
|
||||
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||
}
|
||||
|
||||
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
string remote = source;
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "auth.login", null, null, remote, false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
|
||||
bool returnSession = request.ReturnSession == true;
|
||||
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted);
|
||||
return IssueSession(http, login, request.ReturnSession == true, options);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetupStatusAsync(SetupService setup, CancellationToken ct)
|
||||
{
|
||||
return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetupAsync(HttpContext http, SetupRequest request, SetupService setup, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "setup:");
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
string remote = http.ClientAddress();
|
||||
Result<LoginResult> result = await setup.CompleteAsync(request.ClaimCode, request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "setup.complete", null, null, remote, false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "setup.complete", null, null, remote, true, http.RequestAborted);
|
||||
Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> session = IssueSession(http, login, request.ReturnSession == true, options);
|
||||
return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
|
||||
{
|
||||
if (!returnSession)
|
||||
{
|
||||
http.Response.Cookies.Append(
|
||||
@@ -65,6 +133,98 @@ public static class AuthEndpoints
|
||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
|
||||
}
|
||||
|
||||
/// <summary>这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。</summary>
|
||||
internal static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options)
|
||||
{
|
||||
string own = $"{http.Request.Scheme}://{http.Request.Host}";
|
||||
Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null;
|
||||
string rpId = !string.IsNullOrWhiteSpace(options.WebAuthnRpId) ? options.WebAuthnRpId.Trim() : configured?.Host ?? http.Request.Host.Host;
|
||||
IEnumerable<string> origins = !string.IsNullOrWhiteSpace(options.WebAuthnOrigins)
|
||||
? options.WebAuthnOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(o => o.TrimEnd('/'))
|
||||
: [configured is null ? own : configured.GetLeftPart(UriPartial.Authority)];
|
||||
return new PasskeyContext(rpId, new HashSet<string>(origins, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>限流。<paramref name="scope"/> 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。</summary>
|
||||
private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null)
|
||||
{
|
||||
string source = scope + (http.ClientAddress());
|
||||
ok = limiter.TryAcquire(source, out int retryAfter, limit);
|
||||
return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||
}
|
||||
|
||||
/// <summary>取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。</summary>
|
||||
private const int PasskeyOptionsPerMinute = 60;
|
||||
|
||||
private static IResult PasskeyLoginOptions(HttpContext http, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "passkey-options:", limit: PasskeyOptionsPerMinute);
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
RequestOptions o = passkeys.BeginLogin(PasskeyContextOf(http, options));
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok);
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
Result<LoginResult> result = await passkeys.LoginAsync(
|
||||
new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
string? remote = http.ClientAddress();
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Passkey, "auth.login", null, null, remote, true, http.RequestAborted);
|
||||
return IssueSession(http, login, request.ReturnSession == true, options);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPasskeysAsync(HttpContext http, PasskeyService passkeys)
|
||||
{
|
||||
string userId = http.GetPrincipal().UserId ?? "";
|
||||
IReadOnlyList<PasskeyRecord> list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted);
|
||||
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PasskeyCreationOptionsDto(o.Challenge, o.RpId, o.RpName, o.UserId, o.UserName, o.ExcludeCredentials, o.TimeoutMs), ApiJsonContext.Default.PasskeyCreationOptionsDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
Result<PasskeyRecord> result = await passkeys.RegisterAsync(
|
||||
http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
|
||||
? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created)
|
||||
: Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
|
||||
{
|
||||
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
|
||||
? TypedResults.NoContent()
|
||||
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
string? bearer = PrincipalResolver.BearerOf(http);
|
||||
@@ -85,7 +245,7 @@ public static class AuthEndpoints
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
return TypedResults.Json(
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null),
|
||||
ApiJsonContext.Default.MeDto);
|
||||
}
|
||||
}
|
||||
@@ -17,33 +17,57 @@ public static class BuildEndpoints
|
||||
{
|
||||
group.MapGet("/build-info", GetAsync)
|
||||
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
|
||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
|
||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapGet("/releases", ListReleasesAsync)
|
||||
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403);
|
||||
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
|
||||
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409);
|
||||
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapGet("/backend/update", BackendUpdateAsync)
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403);
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
|
||||
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412);
|
||||
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapGet("/backend/update/live", LiveBackendUpdateAsync)
|
||||
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403);
|
||||
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
}
|
||||
|
||||
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
||||
public static void MapPublish(RouteGroupBuilder group)
|
||||
/// <summary>
|
||||
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
|
||||
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
|
||||
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
|
||||
/// </summary>
|
||||
public static void MapOffer(IEndpointRouteBuilder app)
|
||||
{
|
||||
group.MapPost("/releases", PublishReleaseAsync)
|
||||
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
app.MapPost("/api/release/offer", OfferReleaseAsync)
|
||||
.Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release")
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
|
||||
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, ReleaseKeyService keys, PublishReleaseRequest request)
|
||||
{
|
||||
string manifest = request.Manifest ?? "";
|
||||
string signature = request.Signature ?? "";
|
||||
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|
||||
|| !await keys.VerifyAsync(manifest, signature, http.RequestAborted))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
|
||||
}
|
||||
|
||||
return await PublishReleaseAsync(http, releases, request);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, NodeOwnerService owners, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId)))))
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
|
||||
|
||||
@@ -16,14 +16,18 @@ public static class DataEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/raw/chunks", ListChunksAsync)
|
||||
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
|
||||
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.DataRead);
|
||||
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
|
||||
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410);
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410)
|
||||
.Requires(Capability.DataRead);
|
||||
group.MapGet("/events", ListEventsAsync)
|
||||
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
|
||||
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.EventsRead);
|
||||
group.MapGet("/analysis/recompute", RecomputeAsync)
|
||||
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
|
||||
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.DataWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListChunksAsync(
|
||||
|
||||
@@ -15,9 +15,11 @@ public static class LinkEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/links", ListAsync)
|
||||
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403);
|
||||
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
|
||||
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404);
|
||||
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404)
|
||||
.Requires(Capability.FleetWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>可信资产来源:官方发布密钥(只读)和可选的自有发布公钥(默认没有)。改动写审计。</summary>
|
||||
public static class ReleaseKeyEndpoints
|
||||
{
|
||||
private const string Tag = "admin-release-key";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/release-key", GetAsync)
|
||||
.Api("release_key_get", "The release keys this panel trusts: the built-in official key and the optional own key", Tag)
|
||||
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapPut("/release-key", SetAsync)
|
||||
.Api("release_key_set", "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one", Tag)
|
||||
.Returns<ReleaseKeysDto>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapDelete("/release-key", ClearAsync)
|
||||
.Api("release_key_clear", "Stop trusting your own release key; manifests only it signed are dropped", Tag)
|
||||
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(ReleaseKeyService keys, CancellationToken ct) => Json(await keys.GetAsync(ct));
|
||||
|
||||
private static async Task<IResult> SetAsync(HttpContext http, ReleaseKeyService keys, SetReleaseKeyRequest request)
|
||||
{
|
||||
Result<ReleaseKeys> result = await keys.SetCustomAsync(request.PublicKey, http.RequestAborted);
|
||||
if (!result.TryGet(out ReleaseKeys? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.SetDetail("own release key " + view.Custom!.Fingerprint);
|
||||
return Json(view);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearAsync(HttpContext http, ReleaseKeyService keys, ReleaseService releases)
|
||||
{
|
||||
await keys.ClearCustomAsync(http.RequestAborted);
|
||||
await releases.PurgeUnverifiedAsync(http.RequestAborted);
|
||||
http.SetDetail("own release key removed");
|
||||
return Json(await keys.GetAsync(http.RequestAborted));
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<ReleaseKeysDto> Json(ReleaseKeys keys) =>
|
||||
TypedResults.Json(new ReleaseKeysDto(new ReleaseKeyDto(keys.Official.Fingerprint, keys.Official.PublicKey), keys.Custom is null ? null : new ReleaseKeyDto(keys.Custom.Fingerprint, keys.Custom.PublicKey)), ApiJsonContext.Default.ReleaseKeysDto);
|
||||
}
|
||||
@@ -18,33 +18,54 @@ public static class SystemEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/api-tokens", ListTokensAsync)
|
||||
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
|
||||
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.KeysRead);
|
||||
group.MapPost("/api-tokens", CreateTokenAsync)
|
||||
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
.Api("api_tokens_create", "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.KeysManage);
|
||||
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.KeysManage);
|
||||
|
||||
group.MapGet("/capabilities", CapabilitiesAsync)
|
||||
.Api("capabilities_list", "The capability catalog: groups, which ones change trust, which are reserved, and the presets", TokensTag)
|
||||
.Returns<CapabilityCatalogDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.KeysRead);
|
||||
|
||||
group.MapGet("/settings", ListSettingsAsync)
|
||||
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
|
||||
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.SettingsRead);
|
||||
group.MapPatch("/settings", PatchSettingsAsync)
|
||||
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
|
||||
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
|
||||
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.SettingsWrite);
|
||||
|
||||
group.MapGet("/users", ListUsersAsync)
|
||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
|
||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.AccountsRead);
|
||||
group.MapPost("/users", CreateUserAsync)
|
||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.AccountsManage);
|
||||
group.MapPatch("/users/{id}", ChangePasswordAsync)
|
||||
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
|
||||
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.AccountsManage);
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<CapabilityCatalogDto> CapabilitiesAsync() =>
|
||||
TypedResults.Json(
|
||||
new CapabilityCatalogDto(
|
||||
[.. CapabilityCatalog.Groups.Select(g => new CapabilityGroupDto(g, [.. CapabilityCatalog.All.Where(c => c.Group == g).Select(c => new CapabilityDto(c.Id, c.Trust, c.Reserved, c.Implies))]))],
|
||||
[.. CapabilityCatalog.Presets.Select(p => new CapabilityPresetDto(p.Id, p.Capabilities))]),
|
||||
ApiJsonContext.Default.CapabilityCatalogDto);
|
||||
|
||||
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||
{
|
||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
|
||||
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
|
||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
|
||||
@@ -16,26 +16,34 @@ public static class TaskEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/tasks", ListAsync)
|
||||
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
|
||||
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.TasksRead);
|
||||
group.MapPost("/tasks", CreateAsync)
|
||||
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
|
||||
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
|
||||
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404)
|
||||
.Requires(Capability.TasksWrite);
|
||||
group.MapGet("/tasks/{id}", GetAsync)
|
||||
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.TasksRead);
|
||||
group.MapPatch("/tasks/{id}", PatchAsync)
|
||||
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
|
||||
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
|
||||
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412)
|
||||
.Requires(Capability.TasksWrite);
|
||||
group.MapDelete("/tasks/{id}", DeleteAsync)
|
||||
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.TasksWrite);
|
||||
|
||||
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
|
||||
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
|
||||
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
|
||||
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409)
|
||||
.Requires(Capability.TasksWrite);
|
||||
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
|
||||
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
|
||||
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.TasksRead);
|
||||
group.MapPost("/tests/{id}/cancel", CancelAsync)
|
||||
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.TasksWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 信任根(docs/security/access.md 第 8 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
|
||||
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
|
||||
/// </summary>
|
||||
public static class TrustEndpoints
|
||||
{
|
||||
private const string Tag = "admin-trust";
|
||||
|
||||
public static void Map(RouteGroupBuilder guarded)
|
||||
{
|
||||
guarded.MapGet("/trust", StateAsync)
|
||||
.Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag)
|
||||
.Returns<TrustStateDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
guarded.MapPost("/trust", CommitAsync)
|
||||
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag)
|
||||
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.AccountsManage);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||
{
|
||||
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
string sh = InstallScripts.Sha256(InstallScripts.Sh);
|
||||
string ps1 = InstallScripts.Sha256(InstallScripts.Ps1);
|
||||
return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto);
|
||||
}
|
||||
|
||||
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
|
||||
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
|
||||
{
|
||||
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
|
||||
{
|
||||
if (await releases.GetAsync(component, ct) is not { } offer)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest);
|
||||
if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1))
|
||||
{
|
||||
return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||
{
|
||||
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
||||
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
|
||||
if (!result.TryGet(out TrustState? state, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点
|
||||
http.SetDetail($"operator set v{state.Version}");
|
||||
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public static class ApiModule
|
||||
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<SseLimiter>();
|
||||
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
|
||||
services.AddSingleton<ClientAddressResolver>();
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
JsonSerializerOptions json = options.SerializerOptions;
|
||||
@@ -103,19 +105,22 @@ public static class ApiModule
|
||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||
AuthEndpoints.MapLogin(admin);
|
||||
|
||||
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
|
||||
|
||||
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
|
||||
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
|
||||
AuthEndpoints.MapSession(guarded);
|
||||
AgentEndpoints.Map(guarded);
|
||||
TaskEndpoints.Map(guarded);
|
||||
DataEndpoints.Map(guarded);
|
||||
AuditEndpoints.Map(guarded);
|
||||
TrustEndpoints.Map(guarded);
|
||||
SystemEndpoints.Map(guarded);
|
||||
BuildEndpoints.Map(guarded);
|
||||
ReleaseKeyEndpoints.Map(guarded);
|
||||
LinkEndpoints.Map(guarded);
|
||||
|
||||
OpsEndpoints.Map(app);
|
||||
BuildEndpoints.MapOffer(app);
|
||||
app.MapOpenApi("/openapi/v1.json");
|
||||
app.MapStaticSite();
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳;
|
||||
/// 只有配置了 <see cref="BackendOptions.TrustedProxies"/>,并且连进来的正是其中之一,才相信转发头,
|
||||
/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。
|
||||
/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。
|
||||
/// </summary>
|
||||
public sealed class ClientAddressResolver
|
||||
{
|
||||
private readonly IPNetwork[] _trusted;
|
||||
private readonly string _header;
|
||||
|
||||
public ClientAddressResolver(BackendOptions options)
|
||||
{
|
||||
_header = string.IsNullOrWhiteSpace(options.TrustedProxyHeader) ? "X-Forwarded-For" : options.TrustedProxyHeader.Trim();
|
||||
_trusted = [.. (options.TrustedProxies ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(ParseNetwork)];
|
||||
}
|
||||
|
||||
private static IPNetwork ParseNetwork(string text)
|
||||
{
|
||||
if (IPNetwork.TryParse(text, out IPNetwork network))
|
||||
{
|
||||
return network;
|
||||
}
|
||||
|
||||
if (IPAddress.TryParse(text, out IPAddress? single))
|
||||
{
|
||||
return new IPNetwork(single, single.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"TrustedProxies: '{text}' is not an IP address or CIDR range");
|
||||
}
|
||||
|
||||
private bool IsTrusted(IPAddress address)
|
||||
{
|
||||
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||
return _trusted.Any(n => n.Contains(normalized));
|
||||
}
|
||||
|
||||
public string Resolve(HttpContext http)
|
||||
{
|
||||
IPAddress? remote = http.Connection.RemoteIpAddress;
|
||||
if (remote is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (_trusted.Length == 0 || !IsTrusted(remote))
|
||||
{
|
||||
return remote.ToString();
|
||||
}
|
||||
|
||||
string[] chain = [.. http.Request.Headers[_header].SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))];
|
||||
for (int i = chain.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (!IPAddress.TryParse(chain[i], out IPAddress? hop))
|
||||
{
|
||||
break; // 不是地址就不信这条头
|
||||
}
|
||||
|
||||
if (!IsTrusted(hop))
|
||||
{
|
||||
return hop.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return remote.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public static class ClientAddressExtensions
|
||||
{
|
||||
public static string ClientAddress(this HttpContext http) => http.RequestServices.GetRequiredService<ClientAddressResolver>().Resolve(http);
|
||||
}
|
||||
@@ -9,6 +9,19 @@ public static class EndpointConventions
|
||||
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
||||
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
|
||||
|
||||
/// <summary>声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 <c>x-moshiro-capability</c>。</summary>
|
||||
public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) =>
|
||||
{
|
||||
operation.Extensions ??= new Dictionary<string, Microsoft.OpenApi.IOpenApiExtension>();
|
||||
operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(null));
|
||||
|
||||
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
|
||||
builder.Produces<T>(status);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
@@ -53,17 +54,29 @@ public static class PrincipalResolver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class AdminGuard
|
||||
/// <summary>
|
||||
/// 端点声明自己需要的能力(<see cref="EndpointConventions.Requires"/>)。<c>Capability == null</c> 表示匿名可访问(登录)。
|
||||
/// 没有声明的 <c>/api/admin</c> 端点一律拒绝(并有对账测试保证不存在)。
|
||||
/// </summary>
|
||||
public sealed record CapabilityRequirement(string? Capability);
|
||||
|
||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class CapabilityGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (!principal.IsAdmin)
|
||||
CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata<CapabilityRequirement>();
|
||||
if (requirement is null)
|
||||
{
|
||||
return principal.Scope != ApiScope.None
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
|
||||
return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration");
|
||||
}
|
||||
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (requirement.Capability is { } capability && !principal.Has(capability))
|
||||
{
|
||||
return principal.Kind != PrincipalKind.Anonymous
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability)
|
||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||
}
|
||||
|
||||
@@ -76,24 +89,35 @@ public static class AdminGuard
|
||||
}
|
||||
}
|
||||
|
||||
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
||||
{
|
||||
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
||||
string action = http.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? http.Request.Path.Value ?? "";
|
||||
if (action.EndsWith("_options", StringComparison.Ordinal))
|
||||
{
|
||||
return await next(context); // 取挑战不是操作
|
||||
}
|
||||
|
||||
string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value));
|
||||
http.Response.OnCompleted(() =>
|
||||
{
|
||||
http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail);
|
||||
return http.RequestServices.GetRequiredService<AuditService>()
|
||||
.RecordAsync(principal, action, target, detail as string, http.ClientAddress(), http.Response.StatusCode < 400).AsTask();
|
||||
});
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
|
||||
public static class ReleaseGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
|
||||
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
|
||||
}
|
||||
|
||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||
public static class PublicGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||
if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
|
||||
}
|
||||
@@ -102,5 +126,5 @@ public static class PublicGuard
|
||||
}
|
||||
|
||||
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
|
||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
|
||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ public static class Mappers
|
||||
public static ProbeDto ToDto(ProbeView v) =>
|
||||
new(
|
||||
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
|
||||
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe));
|
||||
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
|
||||
|
||||
public static SeriesDto ToDto(SeriesResult r, bool live) =>
|
||||
new(
|
||||
@@ -103,7 +103,7 @@ public static class Mappers
|
||||
: null;
|
||||
return new AgentDto(
|
||||
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
|
||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual);
|
||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
|
||||
}
|
||||
|
||||
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
|
||||
@@ -154,9 +154,16 @@ public static class Mappers
|
||||
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
|
||||
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind));
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||
|
||||
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
|
||||
|
||||
|
||||
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
|
||||
|
||||
public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports);
|
||||
|
||||
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs));
|
||||
|
||||
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
|
||||
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Fleet.Core;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
@@ -5,9 +6,24 @@ namespace MoShiro.Backend.Api;
|
||||
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
||||
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
|
||||
|
||||
/// <summary>浏览器 <c>navigator.credentials.create</c> 要的参数;二进制字段都是 base64url。</summary>
|
||||
public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
|
||||
|
||||
/// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
|
||||
/// <param name="AllowCredentials">列出时限定可选的 passkey(凭证 id);登录的挑战不带。</param>
|
||||
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
|
||||
|
||||
/// <param name="Class"><c>enroll</c>(加节点)、<c>revoke</c>(吊销和删除)、<c>credential</c>(改凭证,用一次就没有)。</param>
|
||||
public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
|
||||
|
||||
public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession);
|
||||
|
||||
public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, IReadOnlyList<string> Transports);
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
|
||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c> 或 <c>passkey</c>(能力相同,信任类操作都要提权窗口);API 令牌为空。</param>
|
||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
|
||||
|
||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||
|
||||
@@ -42,7 +58,8 @@ public sealed record AgentDto(
|
||||
bool Ipv6Supported = false,
|
||||
Ipv6DiagnosticDto? Ipv6 = null,
|
||||
bool PublicHostAuto = true,
|
||||
bool Ipv6HostAuto = true);
|
||||
bool Ipv6HostAuto = true,
|
||||
string? AddedBy = null);
|
||||
|
||||
public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State,
|
||||
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
|
||||
@@ -52,14 +69,52 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
||||
|
||||
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent");
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
|
||||
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
/// <summary>面板是不是还没有任何用户(需要首次初始化)。</summary>
|
||||
public sealed record SetupStatusDto(bool NeedsSetup);
|
||||
|
||||
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
|
||||
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
|
||||
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
/// <param name="Env">要存成 <c>.env</c> 的内容(只有公开的值)。</param>
|
||||
/// <param name="Url">compose 文件的下载地址。</param>
|
||||
/// <param name="Sha256">compose 文件的 SHA-256:命令下载之后先核对它。</param>
|
||||
/// <param name="Linux">Linux(Podman):建目录、写 .env、下载、核对哈希、启动,合在一起的一段命令。</param>
|
||||
/// <param name="Macos">macOS(Docker Desktop);Agent 需要 Linux 宿主网络,所以是空的。</param>
|
||||
/// <param name="Windows">Windows(Docker Desktop,PowerShell);Agent 同样是空的。</param>
|
||||
public sealed record ComposeInstallDto(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
|
||||
|
||||
/// <param name="Env">要存成 <c>~/.config/moshiro/<角色>.env</c> 的内容(只有公开的值)。</param>
|
||||
/// <param name="Url">Quadlet 单元的下载地址。</param>
|
||||
/// <param name="Sha256">Quadlet 单元的 SHA-256:命令下载之后先核对它。</param>
|
||||
/// <param name="Command">Linux + Podman(rootless):写 env、下载并核对单元、指定镜像仓库、启动,合在一起的一条命令。</param>
|
||||
public sealed record QuadletInstallDto(string Env, string Url, string Sha256, string Command);
|
||||
|
||||
/// <summary>下一版里新增的 passkey。</summary>
|
||||
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
|
||||
|
||||
/// <param name="Added">相对当前生效的集合新增的 passkey;签名之前必须看一眼是不是自己加的。</param>
|
||||
/// <param name="Removed">相对当前生效的集合移除的 passkey 凭证 id。</param>
|
||||
/// <param name="ServerKeyChanged">服务器密钥有变化(换了新的)。</param>
|
||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredentialDto> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||||
|
||||
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
||||
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
|
||||
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
|
||||
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose, QuadletInstallDto Quadlet);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
@@ -67,7 +122,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
||||
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
|
||||
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null);
|
||||
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
|
||||
|
||||
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
|
||||
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
|
||||
@@ -76,7 +131,8 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
|
||||
|
||||
public sealed record SetLinkRequest(bool? Enabled);
|
||||
|
||||
public sealed record PatchProbeRequest(string? Name);
|
||||
/// <summary>Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</summary>
|
||||
public sealed record PatchProbeRequest(string? Name, string? Isp = null);
|
||||
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
@@ -117,9 +173,19 @@ public sealed record RawChunkMetaDto(
|
||||
bool Partial,
|
||||
int Bytes);
|
||||
|
||||
public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt);
|
||||
public sealed record ApiTokenDto(string Id, string Name, IReadOnlyList<string> Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt);
|
||||
|
||||
public sealed record CreateApiTokenRequest(string Name, string Scope);
|
||||
/// <param name="Capabilities">能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。</param>
|
||||
/// <param name="ExpiresInDays">有效天数;缺省不过期。</param>
|
||||
public sealed record CreateApiTokenRequest(string Name, IReadOnlyList<string> Capabilities, int? ExpiresInDays = null);
|
||||
|
||||
public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList<string> Implies);
|
||||
|
||||
public sealed record CapabilityGroupDto(string Id, IReadOnlyList<CapabilityDto> Capabilities);
|
||||
|
||||
public sealed record CapabilityPresetDto(string Id, IReadOnlyList<string> Capabilities);
|
||||
|
||||
public sealed record CapabilityCatalogDto(IReadOnlyList<CapabilityGroupDto> Groups, IReadOnlyList<CapabilityPresetDto> Presets);
|
||||
|
||||
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
|
||||
|
||||
@@ -134,7 +200,10 @@ public sealed record ChangePasswordRequest(string Password);
|
||||
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
||||
|
||||
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
|
||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null);
|
||||
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
|
||||
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
|
||||
|
||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
|
||||
|
||||
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
|
||||
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
|
||||
@@ -144,6 +213,14 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V
|
||||
|
||||
public sealed record PublishReleaseRequest(string? Manifest, string? Signature);
|
||||
|
||||
/// <summary>一把发布公钥:指纹是 SPKI 的 SHA-256(小写十六进制)。</summary>
|
||||
public sealed record ReleaseKeyDto(string Fingerprint, string PublicKey);
|
||||
|
||||
/// <summary>可信资产来源:官方发布密钥(始终信任)和可选的自有发布公钥(默认没有)。</summary>
|
||||
public sealed record ReleaseKeysDto(ReleaseKeyDto Official, ReleaseKeyDto? Custom);
|
||||
|
||||
public sealed record SetReleaseKeyRequest(string? PublicKey);
|
||||
|
||||
public sealed record BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error);
|
||||
|
||||
public sealed record ConfirmBackendUpdateRequest(long Seq);
|
||||
|
||||
@@ -45,8 +45,11 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(ReleaseDto))]
|
||||
[JsonSerializable(typeof(PageDto<ReleaseDto>))]
|
||||
[JsonSerializable(typeof(PublishReleaseRequest))]
|
||||
[JsonSerializable(typeof(ReleaseKeysDto))]
|
||||
[JsonSerializable(typeof(SetReleaseKeyRequest))]
|
||||
[JsonSerializable(typeof(AboutDto))]
|
||||
[JsonSerializable(typeof(NodeBuildDto))]
|
||||
[JsonSerializable(typeof(NodeOwnerDto))]
|
||||
[JsonSerializable(typeof(BuildInfoDto))]
|
||||
[JsonSerializable(typeof(BackendUpdateDto))]
|
||||
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
|
||||
@@ -70,9 +73,24 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(RawChunkMetaDto))]
|
||||
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
|
||||
[JsonSerializable(typeof(PageDto<EventDto>))]
|
||||
[JsonSerializable(typeof(PageDto<AuditDto>))]
|
||||
[JsonSerializable(typeof(ApiTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
||||
[JsonSerializable(typeof(CapabilityCatalogDto))]
|
||||
[JsonSerializable(typeof(PasskeyCreationOptionsDto))]
|
||||
[JsonSerializable(typeof(PasskeyRequestOptionsDto))]
|
||||
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||
[JsonSerializable(typeof(TrustStateDto))]
|
||||
[JsonSerializable(typeof(SetupStatusDto))]
|
||||
[JsonSerializable(typeof(SetupRequest))]
|
||||
[JsonSerializable(typeof(ComposeInstallDto))]
|
||||
[JsonSerializable(typeof(QuadletInstallDto))]
|
||||
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||
[JsonSerializable(typeof(PasskeyDto))]
|
||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed record EdgePartialDto(bool First, bool Last);
|
||||
|
||||
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
|
||||
|
||||
public sealed record AuditDto(long Id, DateTimeOffset At, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success);
|
||||
|
||||
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
|
||||
|
||||
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
|
||||
@@ -175,9 +177,9 @@ public sealed record HostDto(
|
||||
long? UptimeSeconds,
|
||||
DateTimeOffset At);
|
||||
|
||||
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
|
||||
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
|
||||
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
|
||||
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null);
|
||||
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false);
|
||||
|
||||
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
|
||||
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
|
||||
@@ -185,9 +187,10 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin
|
||||
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
|
||||
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
|
||||
|
||||
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
|
||||
/// <param name="Kind"><c>agent</c> 或 <c>probe</c>。</param>
|
||||
public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
|
||||
|
||||
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
|
||||
public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host);
|
||||
|
||||
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
|
||||
public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
|
||||
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
|
||||
@@ -0,0 +1,9 @@
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>内嵌在程序里的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)。</summary>
|
||||
public sealed class EmbeddedReleaseKey : IReleaseKey
|
||||
{
|
||||
public string Pem => InstallScripts.ReleaseKey();
|
||||
}
|
||||
@@ -1,23 +1,44 @@
|
||||
using System.Reflection;
|
||||
using MoShiro.Backend.Application;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,
|
||||
/// 这里把角色、网关、代码和镜像填进去。Agent 和 Probe 共用同一份,不分开维护。
|
||||
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
|
||||
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/security/access.md 第 10 节)。
|
||||
/// 角色、面板地址、代码都走命令里的环境变量。
|
||||
/// </summary>
|
||||
public static class InstallScripts
|
||||
{
|
||||
public static string Render(string resource, InstallTarget target)
|
||||
public const string Sh = "install.sh";
|
||||
public const string Ps1 = "install.ps1";
|
||||
|
||||
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本里内置的就是这一把,测试核对两边一致。</summary>
|
||||
public static string ReleaseKey() => Read("release-key.pub.pem").Trim();
|
||||
|
||||
public static string Script(string resource) => Read(resource);
|
||||
|
||||
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>probe.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
|
||||
public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null;
|
||||
|
||||
/// <summary>某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。</summary>
|
||||
public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty;
|
||||
|
||||
/// <summary>Quadlet 单元的资源名(<c>agent.container</c>、<c>probe.container</c>);不是这两个就是 null。</summary>
|
||||
public static string? QuadletResource(string file) => file is "agent.container" or "probe.container" ? "quadlet-" + file : null;
|
||||
|
||||
/// <summary>某个角色(agent、probe)的 Quadlet 单元的 SHA-256;其他角色返回空。</summary>
|
||||
public static string QuadletSha256(string? role) => role is "agent" or "probe" ? Sha256("quadlet-" + role + ".container") : string.Empty;
|
||||
|
||||
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
||||
|
||||
private static string Read(string resource)
|
||||
{
|
||||
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
|
||||
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd()
|
||||
.Replace("@@ROLE@@", target.Role, StringComparison.Ordinal)
|
||||
.Replace("@@SERVER@@", target.Server, StringComparison.Ordinal)
|
||||
.Replace("@@CODE@@", target.Code, StringComparison.Ordinal)
|
||||
.Replace("@@IMAGE@@", target.Image, StringComparison.Ordinal);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,18 @@
|
||||
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\probe\compose.yaml" LogicalName="compose-probe.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\backend\compose.yaml" LogicalName="compose-backend.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-agent.container" LogicalName="quadlet-agent.container" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-probe.container" LogicalName="quadlet-probe.container" />
|
||||
</ItemGroup>
|
||||
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
|
||||
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
|
||||
<EmbeddedResource Include="..\..\..\web\display\dist\**\*" Exclude="..\..\..\web\display\dist\**\*.map" LogicalName="www/display/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="Exists('..\..\..\web\admin\dist\index.html')">
|
||||
<EmbeddedResource Include="..\..\..\web\admin\dist\**\*" Exclude="..\..\..\web\admin\dist\**\*.map" LogicalName="www/admin/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -17,32 +17,53 @@ public static class OpsEndpoints
|
||||
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
|
||||
.Returns<HealthDto>().ProducesProblems(503);
|
||||
app.MapGet("/metrics", MetricsAsync)
|
||||
.Api("metrics_get", "Prometheus metrics (admin token)", Tag)
|
||||
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||||
// 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。
|
||||
// Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。
|
||||
app.MapGet("/install", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript"))
|
||||
.Api("install_sh", "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404);
|
||||
app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript"))
|
||||
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
|
||||
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
|
||||
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||
.Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
|
||||
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||
.ExcludeFromDescription();
|
||||
app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain"))
|
||||
.Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag)
|
||||
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
|
||||
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
|
||||
.Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404);
|
||||
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
|
||||
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
||||
// compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
|
||||
// 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。
|
||||
app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file"))
|
||||
.Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404);
|
||||
// Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):Agent、Probe 各一份,静态,命令里先核对 SHA-256。
|
||||
app.MapGet("/install/quadlet/{file}", (HttpContext http, string file) => InstallScripts.QuadletResource(file) is { } resource ? Static(http, resource, "text/plain") : Problems.Create(http, MoShiroErrors.NotFound, "unknown quadlet file"))
|
||||
.Api("install_quadlet", "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
||||
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
|
||||
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
|
||||
}
|
||||
|
||||
private static async Task<IResult> InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType)
|
||||
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
|
||||
{
|
||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
|
||||
if (target is null)
|
||||
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
|
||||
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
|
||||
if (offer is null)
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.NotFound, "the install code is invalid, expired or already used");
|
||||
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
||||
}
|
||||
|
||||
string script = InstallScripts.Render(resource, target);
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Text(script, contentType + "; charset=utf-8");
|
||||
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType)
|
||||
{
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
|
||||
}
|
||||
|
||||
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
||||
@@ -57,11 +78,11 @@ public static class OpsEndpoints
|
||||
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (!principal.IsAdmin)
|
||||
if (!principal.Has(Capability.OpsMetrics))
|
||||
{
|
||||
return principal.CanRead
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required")
|
||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required");
|
||||
return principal.Kind != PrincipalKind.Anonymous
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required")
|
||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||
}
|
||||
|
||||
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
|
||||
|
||||
@@ -7,22 +7,27 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>Agent 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。</summary>
|
||||
/// <summary>
|
||||
/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
|
||||
/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。
|
||||
/// </summary>
|
||||
public static class HostEndpoints
|
||||
{
|
||||
private const string Tag = "public";
|
||||
private const string AgentKind = "agent";
|
||||
private const string ProbeKind = "probe";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/hosts", ListAsync)
|
||||
.Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag)
|
||||
.Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag)
|
||||
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
|
||||
group.MapGet("/agents/{agent_id}/host", HistoryAsync)
|
||||
.Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag)
|
||||
group.MapGet("/hosts/{node_id}", HistoryAsync)
|
||||
.Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag)
|
||||
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct)
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct)
|
||||
{
|
||||
var items = new List<HostItemDto>();
|
||||
foreach (AgentView view in await agents.ListAsync(ct))
|
||||
@@ -33,18 +38,39 @@ public static class HostEndpoints
|
||||
}
|
||||
|
||||
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
|
||||
items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
|
||||
items.Add(new HostItemDto(view.Agent.Id.Value, AgentKind, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
|
||||
}
|
||||
|
||||
foreach (ProbeView view in await probes.ListAsync(ct))
|
||||
{
|
||||
if (view.Revoked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(HostSnapshot? host, bool stale) = hosts.Latest(new NodeId(view.Probe.Id));
|
||||
items.Add(new HostItemDto(view.Probe.Id, ProbeKind, view.Probe.Name, view.Online, stale, host is null ? null : Mappers.Time(host.AtUtcMs), host is null ? null : Mappers.ToDto(host)));
|
||||
}
|
||||
|
||||
return TypedResults.Json(new PageDto<HostItemDto>(items, null), ApiJsonContext.Default.PageDtoHostItemDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct)
|
||||
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct)
|
||||
{
|
||||
AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct);
|
||||
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
||||
bool visible;
|
||||
if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
|
||||
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct);
|
||||
visible = view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http));
|
||||
}
|
||||
else
|
||||
{
|
||||
visible = node_id.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) && await probes.GetAsync(node_id, ct) is { Revoked: false };
|
||||
}
|
||||
|
||||
if (!visible)
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.AgentNotFound, "node not found");
|
||||
}
|
||||
|
||||
if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day)
|
||||
@@ -54,7 +80,7 @@ public static class HostEndpoints
|
||||
|
||||
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long from = now - span;
|
||||
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct);
|
||||
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(new NodeId(node_id), from, now + 60_000, ct);
|
||||
|
||||
// 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
|
||||
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000);
|
||||
@@ -92,6 +118,6 @@ public static class HostEndpoints
|
||||
["rx_bps"] = Average(s => s.NetRxBps),
|
||||
["tx_bps"] = Average(s => s.NetTxBps),
|
||||
};
|
||||
return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
|
||||
return TypedResults.Json(new HostSeriesDto(node_id, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public static class LiveEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> StreamAsync(
|
||||
HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
|
||||
HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
|
||||
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
|
||||
{
|
||||
string ipVersion = ip_version ?? IpVersions.V4;
|
||||
@@ -70,7 +70,7 @@ public static class LiveEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
string client = http.ClientAddress();
|
||||
if (!limiter.TryAcquire(client))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
|
||||
@@ -84,7 +84,7 @@ public static class LiveEndpoints
|
||||
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await http.Response.Body.FlushAsync(http.RequestAborted);
|
||||
|
||||
var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
|
||||
var session = new LiveSession(http, agents, probes, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
|
||||
await session.RunAsync(linked.Token);
|
||||
}
|
||||
@@ -105,6 +105,8 @@ public static class LiveEndpoints
|
||||
{
|
||||
private readonly HttpContext _http;
|
||||
private readonly AgentService _agents;
|
||||
private readonly ProbeService _probes;
|
||||
private readonly HostStatusService _hosts;
|
||||
private readonly LineService _lines;
|
||||
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
|
||||
private readonly string _ipVersion;
|
||||
@@ -124,10 +126,12 @@ public static class LiveEndpoints
|
||||
private long _lastWriteMs;
|
||||
private long _lastPeriodicMs;
|
||||
|
||||
public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
|
||||
public LiveSession(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
|
||||
{
|
||||
_http = http;
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
_hosts = hosts;
|
||||
_lines = lines;
|
||||
_lineKeys = lineKeys;
|
||||
_ipVersion = ipVersion;
|
||||
@@ -171,7 +175,7 @@ public static class LiveEndpoints
|
||||
_nodeSignals.Enqueue(node);
|
||||
break;
|
||||
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
|
||||
_hostSignals[host.AgentId] = host;
|
||||
_hostSignals[host.Node] = host;
|
||||
break;
|
||||
case ChunkSignal chunk:
|
||||
_dirtyLines.Add((chunk.AgentId, chunk.Series));
|
||||
@@ -216,10 +220,20 @@ public static class LiveEndpoints
|
||||
|
||||
foreach (HostSignal host in hostSignals)
|
||||
{
|
||||
AgentView? view = await _agents.GetAsync(host.AgentId, ct);
|
||||
if (host.Node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (await _probes.GetAsync(host.Node.Value, ct) is { Revoked: false } probe)
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(host.Node.Value, "probe", probe.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
AgentView? view = await _agents.GetAsync(host.Node, ct);
|
||||
if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active)
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(host.AgentId.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
await WriteAsync("host", new LiveHostDto(host.Node.Value, "agent", view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +298,15 @@ public static class LiveEndpoints
|
||||
case LiveTopicKind.Hosts:
|
||||
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null))
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, "agent", view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
|
||||
foreach (ProbeView view in await _probes.ListAsync(ct))
|
||||
{
|
||||
if (!view.Revoked && _hosts.Latest(new NodeId(view.Probe.Id)).Host is { } host)
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(view.Probe.Id, "probe", view.Online, Mappers.ToDto(host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -61,7 +61,7 @@ public static class NodeEndpoints
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
.Where(n => visible.Contains(n.NodeId))
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null))
|
||||
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>一个可提供的前端文件。<c>Html</c> 的页面带 CSP;其余带最严格的 CSP,免得直接打开 SVG 时执行脚本。</summary>
|
||||
public sealed record SiteAsset(byte[] Content, string ContentType, string ETag, string CacheControl, bool Html);
|
||||
|
||||
/// <summary>
|
||||
/// 解析的结果:<c>Asset</c> 是要返回的文件;<c>Redirect</c> 是要跳转的地址(只会是站内固定的路径,不来自请求);都为空就是 404。
|
||||
/// </summary>
|
||||
public readonly record struct SiteResolution(SiteAsset? Asset, string? Redirect);
|
||||
|
||||
/// <summary>
|
||||
/// 内嵌在 Backend 里的前端:展示端在 <c>/</c>,管理端在 <c>/admin/</c>。
|
||||
/// <para>
|
||||
/// 安全做法(线上常见的静态文件穿越和 SPA 回退问题):
|
||||
/// 文件全部是启动时从程序集资源读进内存的<b>白名单</b>,请求时<b>不碰文件系统</b>,所以没有路径穿越和符号链接的问题;
|
||||
/// 路径里出现 <c>..</c>、<c>.</c>、反斜杠、<c>%</c>(没解码干净的编码)、控制字符、空段(<c>//</c>)、过长,一律 404,不去「规范化」它;
|
||||
/// SPA 回退只给没有扩展名的路径,缺失的文件(<c>/assets/x.js</c>)返回 404 而不是 HTML;
|
||||
/// <c>/api</c> 等后端自己的路径由调用方先排除,所以回退不会吞掉 API 的 404。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class StaticSite
|
||||
{
|
||||
public const string AdminPrefix = "/admin/";
|
||||
private const int MaxPathLength = 1024;
|
||||
|
||||
private readonly Dictionary<string, SiteAsset> _files = new(StringComparer.Ordinal);
|
||||
private readonly SiteAsset? _displayIndex;
|
||||
private readonly SiteAsset? _adminIndex;
|
||||
|
||||
/// <param name="files">(站内路径,内容);站内路径以 <c>/</c> 开头,管理端的以 <c>/admin/</c> 开头。</param>
|
||||
public StaticSite(IEnumerable<(string Path, byte[] Content)> files)
|
||||
{
|
||||
foreach ((string path, byte[] content) in files)
|
||||
{
|
||||
if (!IsCleanPath(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_files[path] = Describe(path, content);
|
||||
}
|
||||
|
||||
_displayIndex = _files.GetValueOrDefault("/index.html");
|
||||
_adminIndex = _files.GetValueOrDefault(AdminPrefix + "index.html");
|
||||
if (_displayIndex is not null)
|
||||
{
|
||||
_files["/"] = _displayIndex;
|
||||
}
|
||||
|
||||
if (_adminIndex is not null)
|
||||
{
|
||||
_files[AdminPrefix] = _adminIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => _files.Count;
|
||||
|
||||
/// <summary>从程序集的嵌入资源读取:<c>www/display/…</c> 对应 <c>/…</c>,<c>www/admin/…</c> 对应 <c>/admin/…</c>。没有嵌入前端(本地没构建)时是空站点。</summary>
|
||||
public static StaticSite FromAssembly(Assembly assembly)
|
||||
{
|
||||
var files = new List<(string, byte[])>();
|
||||
foreach (string name in assembly.GetManifestResourceNames())
|
||||
{
|
||||
string? url = name.StartsWith("www/display/", StringComparison.Ordinal) ? "/" + name["www/display/".Length..]
|
||||
: name.StartsWith("www/admin/", StringComparison.Ordinal) ? AdminPrefix + name["www/admin/".Length..]
|
||||
: null;
|
||||
if (url is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using Stream stream = assembly.GetManifestResourceStream(name)!;
|
||||
using var memory = new MemoryStream();
|
||||
stream.CopyTo(memory);
|
||||
files.Add((url.Replace('\\', '/'), memory.ToArray()));
|
||||
}
|
||||
|
||||
return new StaticSite(files);
|
||||
}
|
||||
|
||||
public SiteResolution Resolve(string? path)
|
||||
{
|
||||
if (!IsCleanPath(path))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (_files.TryGetValue(path, out SiteAsset? exact))
|
||||
{
|
||||
return new SiteResolution(exact, null);
|
||||
}
|
||||
|
||||
if (path == "/admin")
|
||||
{
|
||||
return _adminIndex is null ? default : new SiteResolution(null, AdminPrefix);
|
||||
}
|
||||
|
||||
// 缺失的文件不回退成 HTML(否则脚本和样式 404 变成「HTML 当脚本」,很难排查,也可能被缓存污染)
|
||||
string last = path[(path.LastIndexOf('/') + 1)..];
|
||||
if (last.Contains('.', StringComparison.Ordinal))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
SiteAsset? index = path.StartsWith(AdminPrefix, StringComparison.Ordinal) ? _adminIndex : _displayIndex;
|
||||
return index is null ? default : new SiteResolution(index, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 路径是不是「干净」的:以 <c>/</c> 开头、不过长、没有反斜杠、<c>%</c>、控制字符、<c>.</c> / <c>..</c> 段和中间的空段。
|
||||
/// 不「规范化」,而是直接拒绝——规范化正是各种穿越绕过(<c>%2e%2e</c>、<c>..%5c</c>、双重编码)的来源。
|
||||
/// </summary>
|
||||
public static bool IsCleanPath([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || path[0] != '/' || path.Length > MaxPathLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (char c in path)
|
||||
{
|
||||
if (c is '\\' or '%' or '\0' || c < 0x20 || c == 0x7f || c > 0x7e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
string[] segments = path.Split('/');
|
||||
for (int i = 1; i < segments.Length; i++)
|
||||
{
|
||||
string segment = segments[i];
|
||||
bool trailing = i == segments.Length - 1;
|
||||
if (segment is "." or ".." || (segment.Length == 0 && !trailing))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SiteAsset Describe(string path, byte[] content)
|
||||
{
|
||||
string extension = Path.GetExtension(path).ToLowerInvariant();
|
||||
bool html = extension == ".html";
|
||||
string etag = "\"" + Convert.ToHexStringLower(SHA256.HashData(content))[..32] + "\"";
|
||||
string cache = html ? "no-cache" : path.Contains("/assets/", StringComparison.Ordinal) ? "public, max-age=31536000, immutable" : "public, max-age=3600";
|
||||
return new SiteAsset(content, ContentTypeOf(extension), etag, cache, html);
|
||||
}
|
||||
|
||||
private static string ContentTypeOf(string extension) => extension switch
|
||||
{
|
||||
".html" => "text/html; charset=utf-8",
|
||||
".js" or ".mjs" => "text/javascript; charset=utf-8",
|
||||
".css" => "text/css; charset=utf-8",
|
||||
".svg" => "image/svg+xml",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".webp" => "image/webp",
|
||||
".gif" => "image/gif",
|
||||
".ico" => "image/x-icon",
|
||||
".woff2" => "font/woff2",
|
||||
".woff" => "font/woff",
|
||||
".json" => "application/json",
|
||||
".webmanifest" => "application/manifest+json",
|
||||
".txt" => "text/plain; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>把内嵌前端挂到根路径:只响应 GET 和 HEAD,最低优先级,不覆盖任何后端自己的路径。</summary>
|
||||
public static class StaticSiteEndpoints
|
||||
{
|
||||
/// <summary>后端自己的路径前缀:这些路径没有匹配到端点时要得到后端的 404(JSON),不能被前端的 HTML 回退吞掉。</summary>
|
||||
private static readonly string[] s_reserved = ["/api", "/openapi", "/install", "/healthz"];
|
||||
|
||||
// 页面的 CSP:脚本只许同源;样式允许内联(React 的 style 属性);连接允许 https(管理端可以添加别的后端作为数据源)和本机 http
|
||||
private const string PageCsp =
|
||||
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; "
|
||||
+ "connect-src 'self' https: http://localhost:* http://127.0.0.1:*; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
private const string AssetCsp = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
||||
|
||||
public static IEndpointRouteBuilder MapStaticSite(this IEndpointRouteBuilder app)
|
||||
{
|
||||
StaticSite site = StaticSite.FromAssembly(typeof(StaticSite).Assembly);
|
||||
app.MapMethods("/{**path}", ["GET", "HEAD"], (HttpContext http) => Serve(site, http)).ExcludeFromDescription();
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static IResult Serve(StaticSite site, HttpContext http)
|
||||
{
|
||||
string path = http.Request.Path.Value ?? "/";
|
||||
if (IsReserved(path))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
SiteResolution resolution = site.Resolve(path);
|
||||
if (resolution.Redirect is { } location)
|
||||
{
|
||||
// 固定的站内路径,不拼请求里的任何东西(没有开放重定向,也不依赖 Host)
|
||||
return Results.Redirect(location, permanent: true);
|
||||
}
|
||||
|
||||
if (resolution.Asset is not { } asset)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
IHeaderDictionary headers = http.Response.Headers;
|
||||
headers.XContentTypeOptions = "nosniff";
|
||||
headers["Referrer-Policy"] = "no-referrer";
|
||||
headers.ETag = asset.ETag;
|
||||
headers.CacheControl = asset.CacheControl;
|
||||
headers.ContentSecurityPolicy = asset.Html ? PageCsp : AssetCsp;
|
||||
if (asset.Html)
|
||||
{
|
||||
headers.XFrameOptions = "DENY";
|
||||
headers["Permissions-Policy"] = "publickey-credentials-get=(self), publickey-credentials-create=(self), camera=(), microphone=(), geolocation=()";
|
||||
}
|
||||
|
||||
if (http.Request.Headers.IfNoneMatch.Contains(asset.ETag))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
return Results.Bytes(asset.Content, asset.ContentType);
|
||||
}
|
||||
|
||||
private static bool IsReserved(string path)
|
||||
{
|
||||
foreach (string prefix in s_reserved)
|
||||
{
|
||||
if (path.StartsWith(prefix, StringComparison.Ordinal) && (path.Length == prefix.Length || path[prefix.Length] == '/' || path[prefix.Length] == '.'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,36 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole);
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
|
||||
|
||||
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
|
||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||
/// <summary>
|
||||
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||||
/// </summary>
|
||||
/// <param name="ComposeSha256">面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。</param>
|
||||
/// <param name="QuadletSha256">面板提供的这个角色的 Podman Quadlet 单元的 SHA-256。</param>
|
||||
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = "");
|
||||
|
||||
/// <summary>安装脚本里要填的四个值。</summary>
|
||||
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
|
||||
/// <summary>
|
||||
/// compose 安装:<c>Env</c> 是要存成 <c>.env</c> 的内容(只有公开的值),<c>Url</c> 和 <c>Sha256</c> 是 compose 文件的下载地址和哈希,
|
||||
/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。
|
||||
/// </summary>
|
||||
public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
|
||||
|
||||
/// <summary>
|
||||
/// Podman Quadlet 安装(Linux 上推荐):<c>Env</c> 存成 <c>~/.config/moshiro/<角色>.env</c>,<c>Url</c> 和 <c>Sha256</c> 是 Quadlet 单元的下载地址和哈希,
|
||||
/// <c>Command</c> 把 env、单元、镜像仓库(drop-in)、核对哈希和启动合在一起。单元写了 <c>Pull=always</c>,节点退出再被拉起就是更新,默认自动更新。
|
||||
/// </summary>
|
||||
public sealed record QuadletInstall(string Env, string Url, string Sha256, string Command);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose, QuadletInstall Quadlet);
|
||||
|
||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||
public sealed class JoinTokenService
|
||||
@@ -35,7 +54,7 @@ public sealed class JoinTokenService
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
|
||||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (request.Role is not (AgentRole or ProbeRole))
|
||||
@@ -43,9 +62,9 @@ public sealed class JoinTokenService
|
||||
errors["role"] = "must be agent or probe";
|
||||
}
|
||||
|
||||
if (request.MaxUses is < 1 or > 1000)
|
||||
if (request.MaxUses is < 1 or > 100_000)
|
||||
{
|
||||
errors["max_uses"] = "must be between 1 and 1000";
|
||||
errors["max_uses"] = "must be between 1 and 100000";
|
||||
}
|
||||
|
||||
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
||||
@@ -72,23 +91,116 @@ public sealed class JoinTokenService
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
JoinTokenInfo info = await _store.CreateAsync(
|
||||
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
|
||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
|
||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
|
||||
|
||||
string baseUrl = BaseUrl(requestBaseUrl);
|
||||
string query = $"{baseUrl}/install?code={plaintext}";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(
|
||||
$"curl -fsSL '{query}' | bash",
|
||||
$"curl -fsSL '{query}' | zsh",
|
||||
$"irm '{baseUrl}/install.ps1?code={plaintext}' | iex")));
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Quadlet(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。
|
||||
/// 里面没有任何私钥,码泄露最多让别人注册出一个节点。
|
||||
/// </summary>
|
||||
public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan)
|
||||
{
|
||||
string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'";
|
||||
string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}";
|
||||
string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; ";
|
||||
return new InstallCommands(
|
||||
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
|
||||
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
|
||||
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
|
||||
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把一次性码和公开的值写进本地的 <c>.env</c>,下载静态的 compose 文件、核对哈希、<c>compose up -d</c>:以后更新就是
|
||||
/// <c>compose pull && compose up -d</c>。镜像来自 CI 渠道(<c>:latest</c>,也可以在 .env 里固定版本),不由面板决定。
|
||||
/// </summary>
|
||||
public ComposeInstall Compose(string baseUrl, string role, string code, InstallPlan plan)
|
||||
{
|
||||
string url = $"{baseUrl}/install/compose/{role}.yaml";
|
||||
string dir = $"moshiro-{role}";
|
||||
string Env(bool desktop)
|
||||
{
|
||||
var lines = new List<string> { $"MOSHIRO_SERVER={baseUrl}", $"MOSHIRO_JOIN={code}" };
|
||||
if (plan.Genesis is null)
|
||||
{
|
||||
lines.Add("MOSHIRO_ALLOW_NO_OWNER=1");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"MOSHIRO_GENESIS={plan.Genesis}");
|
||||
lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}");
|
||||
}
|
||||
|
||||
lines.Add($"MOSHIRO_IMAGE={ImageRepository(role)}");
|
||||
if (desktop)
|
||||
{
|
||||
// Docker Desktop 没有宿主网络
|
||||
lines.Add("MOSHIRO_NETWORK_MODE=bridge");
|
||||
lines.Add("MOSHIRO_NETWORK_SCOPE=container");
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
string linux = $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(false)}\nEOF\n"
|
||||
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | sha256sum -c - && podman compose up -d";
|
||||
string macos = role == ProbeRole
|
||||
? $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(true)}\nEOF\n"
|
||||
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | shasum -a 256 -c - && docker compose up -d"
|
||||
: "";
|
||||
string windows = role == ProbeRole
|
||||
? $"New-Item -ItemType Directory -Force {dir} | Out-Null; Set-Location {dir}\n@'\n{Env(true)}\n'@ | Set-Content -Encoding ascii .env\n"
|
||||
+ $"irm '{url}' -OutFile compose.yaml; if ((Get-FileHash compose.yaml -Algorithm SHA256).Hash -ne '{plan.ComposeSha256}') {{ throw 'compose.yaml does not match its published hash' }}\ndocker compose up -d"
|
||||
: "";
|
||||
return new ComposeInstall(Env(false), url, plan.ComposeSha256, linux, macos, windows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Podman Quadlet 安装:env 文件、单元文件(静态,核对哈希)、一个把镜像仓库指向面板配置的 drop-in,然后启动用户服务。
|
||||
/// 自动更新默认开(<c>MOSHIRO_UPDATE_POLICY</c> 缺省是 auto):env 里留了一行注释,去掉 # 就改成在管理端手动点更新。
|
||||
/// </summary>
|
||||
public QuadletInstall Quadlet(string baseUrl, string role, string code, InstallPlan plan)
|
||||
{
|
||||
string url = $"{baseUrl}/install/quadlet/{role}.container";
|
||||
var lines = new List<string> { $"MOSHIRO_SERVER={baseUrl}", $"MOSHIRO_JOIN={code}" };
|
||||
if (plan.Genesis is null)
|
||||
{
|
||||
lines.Add("MOSHIRO_ALLOW_NO_OWNER=1");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"MOSHIRO_GENESIS={plan.Genesis}");
|
||||
lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}");
|
||||
}
|
||||
|
||||
lines.Add("# 自动更新默认开:面板发现新版本后,节点运行满 10 分钟就自动重启,Podman 启动时拉最新镜像。想改成在管理端手动点「更新」,去掉下一行的 #");
|
||||
lines.Add("# MOSHIRO_UPDATE_POLICY=manual");
|
||||
string env = string.Join('\n', lines);
|
||||
string unit = $"$HOME/.config/containers/systemd/moshiro-{role}.container";
|
||||
string command = $"mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-{role}.container.d && cat > ~/.config/moshiro/{role}.env <<'EOF'\n{env}\nEOF\n"
|
||||
+ $"chmod 600 ~/.config/moshiro/{role}.env && curl -fsSL '{url}' -o \"{unit}\" && echo \"{plan.QuadletSha256} {unit}\" | sha256sum -c - "
|
||||
+ $"&& printf '[Container]\\nImage={ImageRepository(role)}:latest\\n' > ~/.config/containers/systemd/moshiro-{role}.container.d/10-image.conf "
|
||||
+ $"&& systemctl --user daemon-reload && systemctl --user start moshiro-{role}";
|
||||
return new QuadletInstall(env, url, plan.QuadletSha256, command);
|
||||
}
|
||||
|
||||
/// <summary>这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。</summary>
|
||||
private string ImageRepository(string role)
|
||||
{
|
||||
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
|
||||
string ns = _options.ImageNamespace.Trim('/');
|
||||
return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}";
|
||||
}
|
||||
|
||||
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||
|
||||
/// <summary>
|
||||
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
|
||||
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
|
||||
/// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。
|
||||
/// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。
|
||||
/// </summary>
|
||||
public async ValueTask<InstallTarget?> ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct)
|
||||
public async ValueTask<string?> RoleOfCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
if (code.Length is 0 or > 200)
|
||||
{
|
||||
@@ -96,22 +208,11 @@ public sealed class JoinTokenService
|
||||
}
|
||||
|
||||
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
|
||||
if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses)
|
||||
{
|
||||
return null;
|
||||
return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
|
||||
? null
|
||||
: RoleOf(token.Kind);
|
||||
}
|
||||
|
||||
string role = RoleOf(token.Kind);
|
||||
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
|
||||
string ns = _options.ImageNamespace.Trim('/');
|
||||
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
|
||||
string server = BaseUrl(requestBaseUrl);
|
||||
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
|
||||
}
|
||||
|
||||
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
|
||||
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
|
||||
|
||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。
|
||||
/// 属主必须是操作员集合里的凭证;<see cref="BackendOptions.RequireOwner"/> 打开时(默认)必须已经有信任根,并且至少选一把——
|
||||
/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。
|
||||
/// </summary>
|
||||
public static class OwnerPolicy
|
||||
{
|
||||
public static Result<string[]> Resolve(bool required, OperatorSet? set, IReadOnlyList<string>? requested)
|
||||
{
|
||||
if (set is null)
|
||||
{
|
||||
return required
|
||||
? ValidationError.Fail<string[]>("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey")
|
||||
: Result<string[]>.Ok([]);
|
||||
}
|
||||
|
||||
string[] chosen = requested is null ? [.. set.Keys.Select(k => k.CredentialId)] : [.. requested.Distinct(StringComparer.Ordinal)];
|
||||
if (chosen.Any(c => set.Keys.All(k => k.CredentialId != c)))
|
||||
{
|
||||
return ValidationError.Fail<string[]>("controllers", "must be passkeys of the operator set");
|
||||
}
|
||||
|
||||
return required && chosen.Length == 0
|
||||
? ValidationError.Fail<string[]>("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to")
|
||||
: Result<string[]>.Ok(chosen);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,14 @@ public sealed partial class AgentService
|
||||
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
private readonly TrustService _trust;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links)
|
||||
private readonly NodeOwnerService _owners;
|
||||
|
||||
public AgentService(NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||
{
|
||||
_owners = owners;
|
||||
_trust = trust;
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
@@ -145,6 +150,7 @@ public sealed partial class AgentService
|
||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||
await _agents.DeleteAsync(id, ct);
|
||||
await _nodes.ForgetAsync(id.Value, ct);
|
||||
_owners.Forget(id.Value);
|
||||
await _links.ForgetNodeAsync(id.Value, ct);
|
||||
_runtime.Remove(id);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
@@ -172,6 +178,9 @@ public sealed partial class AgentService
|
||||
});
|
||||
}
|
||||
|
||||
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
|
||||
result.TrustChain = chain.Count == 0 ? null : [.. chain];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// <c>moshiro.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。
|
||||
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。
|
||||
/// <c>moshiro.host</c>:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。
|
||||
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。
|
||||
/// </summary>
|
||||
public sealed class HostStatusService
|
||||
{
|
||||
|
||||
@@ -69,9 +69,11 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
private readonly ReleaseService _releases;
|
||||
private readonly ScheduleService _schedule;
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly NodeOwnerService _owners;
|
||||
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes)
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
|
||||
{
|
||||
_owners = owners;
|
||||
_releases = releases;
|
||||
_schedule = schedule;
|
||||
_probes = probes;
|
||||
@@ -148,6 +150,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
{
|
||||
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
|
||||
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
|
||||
_owners.Report(id.Value, meta?.Owners);
|
||||
if (meta?.Ipv6 is { } ipv6)
|
||||
{
|
||||
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
||||
@@ -229,6 +232,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
}
|
||||
}
|
||||
|
||||
_owners.Report(id.Value, meta.Owners);
|
||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>节点的一个属主:它报上来的 passkey 凭证 id,以及面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
|
||||
public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username);
|
||||
|
||||
/// <summary>
|
||||
/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。
|
||||
/// <b>属主的强制在节点本地</b>(没有属主的节点不运行,见 <c>OwnerGuard</c>),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上——
|
||||
/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。
|
||||
/// 在内存里:节点每次连上来都会重新报。
|
||||
/// </summary>
|
||||
public sealed class NodeOwnerService
|
||||
{
|
||||
private const int MaxOwners = 16;
|
||||
private const int MaxCredentialIdLength = 256;
|
||||
|
||||
private readonly ConcurrentDictionary<string, string[]> _reported = new(StringComparer.Ordinal);
|
||||
private readonly IAuthStore _auth;
|
||||
|
||||
public NodeOwnerService(IAuthStore auth)
|
||||
{
|
||||
_auth = auth;
|
||||
}
|
||||
|
||||
public void Report(string nodeId, IReadOnlyList<string>? credentialIds)
|
||||
{
|
||||
string[] clean = credentialIds is null ? [] : [.. credentialIds.Where(id => id.Length is > 0 and <= MaxCredentialIdLength).Distinct(StringComparer.Ordinal).Take(MaxOwners)];
|
||||
if (clean.Length == 0)
|
||||
{
|
||||
_reported.TryRemove(nodeId, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
_reported[nodeId] = clean;
|
||||
}
|
||||
}
|
||||
|
||||
public void Forget(string nodeId) => _reported.TryRemove(nodeId, out _);
|
||||
|
||||
/// <summary>每个节点的属主(只含报过的节点)。</summary>
|
||||
public async ValueTask<IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<string, IReadOnlyList<NodeOwner>>(StringComparer.Ordinal);
|
||||
var users = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach ((string nodeId, string[] ids) in _reported)
|
||||
{
|
||||
var owners = new List<NodeOwner>();
|
||||
foreach (string id in ids)
|
||||
{
|
||||
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(id, ct);
|
||||
string? username = null;
|
||||
if (passkey is not null && !users.TryGetValue(passkey.UserId, out username))
|
||||
{
|
||||
username = (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
|
||||
users[passkey.UserId] = username;
|
||||
}
|
||||
|
||||
owners.Add(new NodeOwner(id, passkey?.Name, username));
|
||||
}
|
||||
|
||||
result[nodeId] = owners;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,11 @@ public sealed partial class ProbeService
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
private readonly NodeOwnerService _owners;
|
||||
|
||||
public ProbeService(NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
{
|
||||
_owners = owners;
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
_probes = probes;
|
||||
@@ -94,6 +97,7 @@ public sealed partial class ProbeService
|
||||
if (deleted)
|
||||
{
|
||||
await _nodes.ForgetAsync(id, ct);
|
||||
_owners.Forget(id);
|
||||
await _links.ForgetNodeAsync(id, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,16 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<IngestService>();
|
||||
services.AddSingleton<WindowComputer>();
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<AuditService>();
|
||||
services.AddSingleton<ServerIdentityService>();
|
||||
services.AddSingleton<SetupService>();
|
||||
services.AddSingleton<ReleaseFeed>();
|
||||
services.AddSingleton<ReleaseKeyService>();
|
||||
services.AddSingleton<NodeOwnerService>();
|
||||
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||
services.AddSingleton<TrustService>();
|
||||
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<ScheduleService>();
|
||||
@@ -61,6 +71,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TestFinalizer>());
|
||||
services.AddHostedService<HotLayerBackfill>();
|
||||
services.AddHostedService<RetentionService>();
|
||||
services.AddHostedService<ReleasePoller>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user