Compare commits
| 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 |
No files matched your search
@@ -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 令牌(按能力授权)──▶ 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 │
|
||||
│ Store(段文件 + SQLite) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
操作员 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 后 │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
||||
│ 有公网 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,22 +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/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
|
||||
| [`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.2.0</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。首次导入和自动更新见部署文档。
|
||||
@@ -31,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;
|
||||
@@ -46,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;
|
||||
@@ -235,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;
|
||||
});
|
||||
}
|
||||
@@ -257,7 +257,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
|
||||
{
|
||||
_update.RequestApply();
|
||||
_update?.RequestApply();
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -302,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()],
|
||||
@@ -340,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)
|
||||
{
|
||||
|
||||
@@ -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 类型 | 正文`)上。
|
||||
|
||||
| 保留方法 | 方向 | 说明 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ public static class EnrollmentClient
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public sealed partial class TrustFileContext : JsonSerializerContext;
|
||||
/// <summary>
|
||||
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
|
||||
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
||||
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
|
||||
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
|
||||
/// </summary>
|
||||
public sealed class TrustAnchor : IServerVerifier
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
||||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
|
||||
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||||
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||||
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
|
||||
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
|
||||
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
|
||||
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
|
||||
/// </summary>
|
||||
public static class WebAuthnAssertion
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ 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, genesis, controllers);
|
||||
|
||||
+676
-402
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。首次导入和自动更新见部署文档。
|
||||
@@ -22,17 +22,21 @@ 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;
|
||||
_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),
|
||||
@@ -41,8 +45,8 @@ public sealed class ProbeApp : IDisposable
|
||||
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,
|
||||
@@ -55,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, _) =>
|
||||
@@ -105,6 +109,7 @@ public sealed class ProbeApp : IDisposable
|
||||
}
|
||||
|
||||
Task link = _link.RunAsync(ct);
|
||||
Task host = ReportHostAsync(ct);
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -120,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);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ public static class MoShiroErrors
|
||||
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
|
||||
public static readonly ErrorCode Forbidden = new("forbidden");
|
||||
public static readonly ErrorCode SitePrivate = new("site_private");
|
||||
public static readonly ErrorCode ElevationRequired = new("elevation_required");
|
||||
public static readonly ErrorCode NotFound = new("not_found");
|
||||
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
||||
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -306,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; }
|
||||
|
||||
@@ -316,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; }
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -137,13 +137,13 @@ public static class AgentEndpoints
|
||||
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
|
||||
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
|
||||
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
|
||||
string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]];
|
||||
if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c)))
|
||||
Result<string[]> owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers);
|
||||
if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set"));
|
||||
return Problems.From(http, ownerError);
|
||||
}
|
||||
|
||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
|
||||
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, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
|
||||
@@ -154,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)
|
||||
@@ -179,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)
|
||||
|
||||
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。</summary>
|
||||
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
|
||||
public static class AuditEndpoints
|
||||
{
|
||||
private const string Tag = "admin-audit";
|
||||
|
||||
@@ -19,6 +19,14 @@ public static class AuthEndpoints
|
||||
.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)
|
||||
@@ -51,22 +59,6 @@ public static class AuthEndpoints
|
||||
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/elevation", ListElevation)
|
||||
.Api("auth_elevation_list", "The elevation windows this session has open (sensitive actions need one)", Tag)
|
||||
.Returns<PageDto<ElevationDto>>().ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapPost("/auth/elevation/options", ElevationOptionsAsync)
|
||||
.Api("auth_elevation_options", "A challenge for opening an elevation window with a passkey touch", Tag)
|
||||
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(400, 401, 403, 429)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapPost("/auth/elevation", ElevateAsync)
|
||||
.Api("auth_elevate", "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", Tag)
|
||||
.Returns<ElevationDto>().ProducesProblems(400, 401, 403, 429)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapDelete("/auth/elevation", EndElevation)
|
||||
.Api("auth_elevation_end", "Close all elevation windows of this session", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/me", Me)
|
||||
.Api("auth_me", "The current identity", Tag)
|
||||
.Returns<MeDto>().ProducesProblems(401)
|
||||
@@ -93,6 +85,32 @@ public static class AuthEndpoints
|
||||
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)
|
||||
{
|
||||
@@ -179,20 +197,8 @@ public static class AuthEndpoints
|
||||
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
|
||||
}
|
||||
|
||||
/// <summary>已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。</summary>
|
||||
private static async Task<IResult?> CredentialGateAsync(HttpContext http, PasskeyService passkeys, bool consume)
|
||||
{
|
||||
string? userId = http.GetPrincipal().UserId;
|
||||
return userId is null || (await passkeys.ListAsync(userId, http.RequestAborted)).Count == 0 ? null : ElevationGate.Require(http, ElevationClass.Credential, consume);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
|
||||
{
|
||||
@@ -205,11 +211,6 @@ public static class AuthEndpoints
|
||||
|
||||
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -219,89 +220,13 @@ public static class AuthEndpoints
|
||||
|
||||
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
|
||||
{
|
||||
if (ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
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 Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<PageDto<ElevationDto>> ListElevation(HttpContext http, ElevationService elevation)
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
IReadOnlyList<ElevationWindow> open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : [];
|
||||
return TypedResults.Json(new PageDto<ElevationDto>([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ElevationOptionsAsync(HttpContext http, ElevationRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "elevation-options:", limit: PasskeyOptionsPerMinute);
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
||||
}
|
||||
|
||||
Result<RequestOptions> result = await passkeys.BeginElevationAsync(http.GetPrincipal(), elevation, PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out RequestOptions? o, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs, o.AllowCredentials), ApiJsonContext.Default.PasskeyRequestOptionsDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ElevateAsync(HttpContext http, ElevationAssertionRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "elevation:");
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
||||
}
|
||||
|
||||
Principal principal = http.GetPrincipal();
|
||||
Result<ElevationWindow> result = await passkeys.ElevateAsync(
|
||||
principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out ElevationWindow? window, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.ClientAddress(), false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.SetDetail(Elevation.IdOf(elevation));
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(Mappers.ToDto(window), ApiJsonContext.Default.ElevationDto);
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.NoContent EndElevation(HttpContext http, ElevationService elevation)
|
||||
{
|
||||
if (http.GetPrincipal().SessionId is { } session)
|
||||
{
|
||||
elevation.End(session);
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth, ElevationService elevation)
|
||||
{
|
||||
if (http.GetPrincipal().SessionId is { } own)
|
||||
{
|
||||
elevation.End(own);
|
||||
}
|
||||
|
||||
string? bearer = PrincipalResolver.BearerOf(http);
|
||||
if (bearer is not null)
|
||||
{
|
||||
|
||||
@@ -36,21 +36,38 @@ public static class BuildEndpoints
|
||||
.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)
|
||||
.Requires(Capability.ReleasePublish);
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -64,11 +64,6 @@ public static class SystemEndpoints
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||
{
|
||||
if (CapabilityCatalog.Expand(request.Capabilities ?? []).Any(c => CapabilityCatalog.Find(c)?.Trust == true) && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||
{
|
||||
return needed; // 带信任类能力的 API key 等于把信任操作交给一份密钥:创建它要用 passkey 签一次
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -8,7 +8,7 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
|
||||
/// 信任根(docs/security/access.md 第 8 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
|
||||
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
|
||||
/// </summary>
|
||||
public static class TrustEndpoints
|
||||
@@ -22,10 +22,9 @@ public static class TrustEndpoints
|
||||
.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 and needs a credential window)", Tag)
|
||||
.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)
|
||||
.ElevationExempt();
|
||||
.Requires(Capability.AccountsManage);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||
@@ -60,12 +59,6 @@ public static class TrustEndpoints
|
||||
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||
{
|
||||
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
||||
// 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根
|
||||
if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
|
||||
if (!result.TryGet(out TrustState? state, out FleetError? error))
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 =>
|
||||
{
|
||||
@@ -104,8 +105,6 @@ public static class ApiModule
|
||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||
AuthEndpoints.MapLogin(admin);
|
||||
|
||||
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
|
||||
|
||||
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
|
||||
AuthEndpoints.MapSession(guarded);
|
||||
AgentEndpoints.Map(guarded);
|
||||
@@ -115,10 +114,13 @@ public static class ApiModule
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,6 @@ public static class EndpointConventions
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。
|
||||
/// </summary>
|
||||
public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker());
|
||||
|
||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(null));
|
||||
|
||||
@@ -60,9 +60,6 @@ public static class PrincipalResolver
|
||||
/// </summary>
|
||||
public sealed record CapabilityRequirement(string? Capability);
|
||||
|
||||
/// <summary>端点自己验 passkey 签名(见 <see cref="EndpointConventions.ElevationExempt"/>)。</summary>
|
||||
public sealed record ElevationExemptMarker;
|
||||
|
||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class CapabilityGuard
|
||||
{
|
||||
@@ -92,12 +89,6 @@ public static class CapabilityGuard
|
||||
}
|
||||
}
|
||||
|
||||
// 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里
|
||||
if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata<ElevationExemptMarker>() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
||||
{
|
||||
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
||||
@@ -120,24 +111,6 @@ public static class CapabilityGuard
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。</summary>
|
||||
public static class ElevationGate
|
||||
{
|
||||
/// <summary>没问题返回 null;否则 403 <c>elevation_required</c>,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。</summary>
|
||||
public static IResult? Require(HttpContext http, ElevationClass elevation, bool consume)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (principal.UserId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal.SessionId is { } session && http.RequestServices.GetRequiredService<ElevationService>().Check(session, elevation, consume)
|
||||
? null
|
||||
: Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||
public static class PublicGuard
|
||||
{
|
||||
|
||||
@@ -156,9 +156,8 @@ public static class Mappers
|
||||
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), t.CreatedBy);
|
||||
|
||||
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), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||
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 ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap
|
||||
|
||||
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
||||
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
|
||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||
Add(
|
||||
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
||||
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
|
||||
|
||||
@@ -10,16 +10,10 @@ public sealed record LoginRequest(string Username, string Password, bool? Return
|
||||
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>
|
||||
/// <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 ElevationRequest(string Class);
|
||||
|
||||
public sealed record ElevationAssertionRequest(string Class, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||
|
||||
public sealed record ElevationDto(string Class, DateTimeOffset ExpiresAt);
|
||||
|
||||
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);
|
||||
@@ -28,7 +22,7 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c>(没有任何能力,只能注册 passkey)或 <c>passkey</c>;API 令牌为空。</param>
|
||||
/// <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);
|
||||
@@ -81,19 +75,46 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
|
||||
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 TrustProposalDto(string Statement, int Version, string Challenge);
|
||||
/// <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);
|
||||
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);
|
||||
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");
|
||||
@@ -110,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,
|
||||
@@ -178,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);
|
||||
@@ -188,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))]
|
||||
@@ -81,10 +84,11 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||
[JsonSerializable(typeof(TrustStateDto))]
|
||||
[JsonSerializable(typeof(ElevationRequest))]
|
||||
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
||||
[JsonSerializable(typeof(ElevationDto))]
|
||||
[JsonSerializable(typeof(PageDto<ElevationDto>))]
|
||||
[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))]
|
||||
|
||||
@@ -177,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);
|
||||
@@ -187,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();
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
|
||||
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
|
||||
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/security/access.md 第 10 节)。
|
||||
/// 角色、面板地址、代码都走命令里的环境变量。
|
||||
/// </summary>
|
||||
public static class InstallScripts
|
||||
@@ -19,6 +19,18 @@ public static class InstallScripts
|
||||
|
||||
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))));
|
||||
|
||||
|
||||
@@ -10,5 +10,17 @@
|
||||
<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>
|
||||
@@ -33,6 +33,15 @@ public static class OpsEndpoints
|
||||
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");
|
||||
@@ -40,9 +49,8 @@ public static class OpsEndpoints
|
||||
|
||||
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);
|
||||
ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted);
|
||||
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, "no signed release is registered for this install code");
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,23 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows)
|
||||
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||||
/// </summary>
|
||||
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
|
||||
/// <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 = "");
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||
/// <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>安装脚本里要填的四个值。</summary>
|
||||
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
|
||||
/// <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
|
||||
@@ -82,7 +93,7 @@ public sealed class JoinTokenService
|
||||
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, request.CreatedBy, ct);
|
||||
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||||
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>
|
||||
@@ -101,13 +112,95 @@ public sealed class JoinTokenService
|
||||
+ $"$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)
|
||||
{
|
||||
@@ -115,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;
|
||||
}
|
||||
|
||||
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;
|
||||
return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
|
||||
? null
|
||||
: RoleOf(token.Kind);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,11 @@ public sealed partial class AgentService
|
||||
private readonly LinkService _links;
|
||||
private readonly TrustService _trust;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||
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;
|
||||
@@ -147,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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -38,10 +38,13 @@ public static class ApplicationServiceCollectionExtensions
|
||||
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<ElevationService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<ScheduleService>();
|
||||
@@ -68,6 +71,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TestFinalizer>());
|
||||
services.AddHostedService<HotLayerBackfill>();
|
||||
services.AddHostedService<RetentionService>();
|
||||
services.AddHostedService<ReleasePoller>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@ public enum PrincipalKind
|
||||
/// </summary>
|
||||
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
|
||||
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):登录方式只用于审计和显示;敏感操作靠提权窗口(触碰 passkey)。API key 为空。</summary>
|
||||
public string AuthMethod { get; init; } = "";
|
||||
|
||||
/// <summary>会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。</summary>
|
||||
/// <summary>会话的标识(令牌哈希的十六进制):API key 为空。</summary>
|
||||
public string? SessionId { get; init; }
|
||||
|
||||
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
|
||||
|
||||
@@ -71,7 +71,7 @@ public sealed partial class AuthService
|
||||
}
|
||||
|
||||
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession(session.AuthMethod)), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
||||
}
|
||||
|
||||
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 能力(capability):一项具体的权限。端点声明自己需要哪一项,<see cref="Principal.Has"/> 是唯一的判断入口,
|
||||
/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。
|
||||
/// 会话、API key 都走它。设计见 docs/security/access.md 第 3 节。
|
||||
/// </summary>
|
||||
public static class Capability
|
||||
{
|
||||
@@ -27,7 +27,6 @@ public static class Capability
|
||||
|
||||
public const string UpdateRead = "update.read";
|
||||
public const string UpdateConfirm = "update.confirm";
|
||||
public const string ReleasePublish = "release.publish";
|
||||
|
||||
public const string KeysRead = "keys.read";
|
||||
public const string KeysManage = "keys.manage";
|
||||
@@ -39,13 +38,13 @@ public static class Capability
|
||||
public const string EventsRead = "events.read";
|
||||
public const string OpsMetrics = "ops.metrics";
|
||||
|
||||
// 预留:外部 webhook 回调和数据拉取(docs/access.md 5.4),目录里先存在,实现放后面
|
||||
// 预留:外部 webhook 回调和数据拉取(docs/security/access.md 6.3),目录里先存在,实现放后面
|
||||
public const string DataExport = "data.export";
|
||||
public const string WebhooksRead = "webhooks.read";
|
||||
public const string WebhooksManage = "webhooks.manage";
|
||||
}
|
||||
|
||||
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;<c>Reserved</c>:还没有对应的功能。</summary>
|
||||
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任或有破坏性的能力(吊销、账户),界面上特别标出,授权与其他能力一样靠 API key 或登录会话;真正由 passkey 签名保护的是操作员集合,那是节点自己验证的;<c>Reserved</c>:还没有对应的功能。</summary>
|
||||
public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
|
||||
|
||||
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
|
||||
@@ -74,7 +73,6 @@ public static class CapabilityCatalog
|
||||
|
||||
new(Capability.UpdateRead, "update"),
|
||||
new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead),
|
||||
new(Capability.ReleasePublish, "update"),
|
||||
|
||||
new(Capability.KeysRead, "keys"),
|
||||
new(Capability.KeysManage, "keys", false, false, Capability.KeysRead),
|
||||
@@ -99,14 +97,11 @@ public static class CapabilityCatalog
|
||||
new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]),
|
||||
new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])),
|
||||
new("enroll", Expand([Capability.FleetEnroll])),
|
||||
new("ci-release", [Capability.ReleasePublish]),
|
||||
new("all", [.. All.Select(c => c.Id)]),
|
||||
];
|
||||
|
||||
public static bool IsKnown(string id) => s_byId.ContainsKey(id);
|
||||
|
||||
public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id);
|
||||
|
||||
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
|
||||
public static IReadOnlyList<string> Expand(IEnumerable<string> ids)
|
||||
{
|
||||
@@ -132,12 +127,10 @@ public static class CapabilityCatalog
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>)。</summary>
|
||||
/// <summary>
|
||||
/// 登录会话的能力:全部,包括信任类,密码登录和 passkey 登录一样。区别在于敏感操作:信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>),
|
||||
/// 窗口要当场触碰 passkey 才能开,所以密码泄露的人做不了这些,日常管理却不需要每次触碰。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
||||
|
||||
/// <summary>
|
||||
/// 按登录方式给会话能力:passkey 登录的拿全部;<b>密码登录的什么能力都没有</b>,只能做「已登录」就能做的事——
|
||||
/// 看自己是谁、注册自己的第一个 passkey。管理员要先用密码进来加一个 passkey,之后用 passkey 登录才能管事。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ForSession(string authMethod) => authMethod == AuthMethods.Passkey ? ForSession() : [];
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。
|
||||
/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。
|
||||
/// </summary>
|
||||
public enum ElevationClass
|
||||
{
|
||||
Enroll,
|
||||
Revoke,
|
||||
Credential,
|
||||
}
|
||||
|
||||
public sealed record ElevationWindow(ElevationClass Class, long ExpiresAtMs);
|
||||
|
||||
public static class Elevation
|
||||
{
|
||||
public static string IdOf(ElevationClass elevation) => elevation switch
|
||||
{
|
||||
ElevationClass.Enroll => "enroll",
|
||||
ElevationClass.Revoke => "revoke",
|
||||
_ => "credential",
|
||||
};
|
||||
|
||||
public static bool TryParse(string? id, out ElevationClass elevation)
|
||||
{
|
||||
(bool ok, elevation) = id switch
|
||||
{
|
||||
"enroll" => (true, ElevationClass.Enroll),
|
||||
"revoke" => (true, ElevationClass.Revoke),
|
||||
"credential" => (true, ElevationClass.Credential),
|
||||
_ => (false, default),
|
||||
};
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。</summary>
|
||||
public static ElevationClass? For(string capability) => capability switch
|
||||
{
|
||||
Capability.FleetRevoke => ElevationClass.Revoke,
|
||||
Capability.AccountsManage => ElevationClass.Credential,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。
|
||||
/// </summary>
|
||||
public sealed class ElevationService
|
||||
{
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, Dictionary<ElevationClass, long>> _windows = [];
|
||||
|
||||
public ElevationService(BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public TimeSpan LifetimeOf(ElevationClass elevation) => elevation switch
|
||||
{
|
||||
ElevationClass.Enroll => TimeSpan.FromMinutes(_options.ElevationEnrollMinutes),
|
||||
ElevationClass.Revoke => TimeSpan.FromMinutes(_options.ElevationRevokeMinutes),
|
||||
_ => TimeSpan.FromSeconds(_options.ElevationCredentialSeconds),
|
||||
};
|
||||
|
||||
/// <summary>开窗口;已经有的窗口重新计时。返回到期时间。</summary>
|
||||
public long Grant(string sessionId, ElevationClass elevation)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long expires = now + (long)LifetimeOf(elevation).TotalMilliseconds;
|
||||
lock (_gate)
|
||||
{
|
||||
Prune(now);
|
||||
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own))
|
||||
{
|
||||
_windows[sessionId] = own = [];
|
||||
}
|
||||
|
||||
own[elevation] = expires;
|
||||
}
|
||||
|
||||
return expires;
|
||||
}
|
||||
|
||||
/// <summary>窗口是否有效。<paramref name="consume"/> 且是凭证类:同时用掉(一次有效)。</summary>
|
||||
public bool Check(string sessionId, ElevationClass elevation, bool consume)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own) || !own.TryGetValue(elevation, out long expires) || expires <= now)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (consume && elevation == ElevationClass.Credential)
|
||||
{
|
||||
own.Remove(elevation);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ElevationWindow> Active(string sessionId)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
lock (_gate)
|
||||
{
|
||||
return _windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own)
|
||||
? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>主动结束全部窗口(点「结束」或退出登录)。</summary>
|
||||
public void End(string sessionId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_windows.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void Prune(long now)
|
||||
{
|
||||
foreach (string session in _windows.Where(p => p.Value.All(w => w.Value <= now)).Select(p => p.Key).ToList())
|
||||
{
|
||||
_windows.Remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorD
|
||||
/// <summary>
|
||||
/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期;
|
||||
/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 <see cref="WebAuthnAssertion"/>(节点以后也用)。
|
||||
/// 设计见 docs/access.md 第 3 节。
|
||||
/// 设计见 docs/security/access.md 第 4 节。
|
||||
/// </summary>
|
||||
public sealed class PasskeyService
|
||||
{
|
||||
@@ -33,7 +33,6 @@ public sealed class PasskeyService
|
||||
|
||||
private readonly IAuthStore _store;
|
||||
private readonly AuthService _auth;
|
||||
private readonly ElevationService _elevation;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, PendingChallenge> _challenges = [];
|
||||
@@ -43,13 +42,10 @@ public sealed class PasskeyService
|
||||
private const string RegisterPurpose = "register";
|
||||
private const string LoginPurpose = "login";
|
||||
|
||||
private static string ElevatePurpose(ElevationClass elevation) => "elevate:" + Elevation.IdOf(elevation);
|
||||
|
||||
public PasskeyService(IAuthStore store, AuthService auth, ElevationService elevation, TimeProvider time)
|
||||
public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_auth = auth;
|
||||
_elevation = elevation;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
@@ -118,37 +114,6 @@ public sealed class PasskeyService
|
||||
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
|
||||
}
|
||||
|
||||
/// <summary>提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。</summary>
|
||||
public async ValueTask<Result<RequestOptions>> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
if (principal.UserId is not { } userId || principal.SessionId is null)
|
||||
{
|
||||
return Result<RequestOptions>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
|
||||
}
|
||||
|
||||
IReadOnlyList<PasskeyRecord> own = await _store.ListPasskeysAsync(userId, ct);
|
||||
if (own.Count == 0)
|
||||
{
|
||||
return Result<RequestOptions>.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)");
|
||||
}
|
||||
|
||||
return Result<RequestOptions>.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)]));
|
||||
}
|
||||
|
||||
/// <summary>验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。</summary>
|
||||
public async ValueTask<Result<ElevationWindow>> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId)
|
||||
{
|
||||
return Result<ElevationWindow>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
|
||||
}
|
||||
|
||||
PasskeyRecord? passkey = await VerifyAsync(request, context, ElevatePurpose(elevation), userId, ct);
|
||||
return passkey is null || passkey.UserId != userId
|
||||
? Result<ElevationWindow>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
|
||||
: Result<ElevationWindow>.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation)));
|
||||
}
|
||||
|
||||
/// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
|
||||
private async ValueTask<PasskeyRecord?> VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Security.Cryptography;
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 首次认领:面板里还没有任何用户时,由部署的人自己填管理员的用户名和密码——没有默认账户,也没有默认密码。
|
||||
/// <para>
|
||||
/// 谁先打开页面谁就能当管理员,所以要有一个只有部署的人才拿得到的<b>认领码</b>:首次启动时随机生成并写进后端的日志
|
||||
/// (<c>journalctl -u moshiro-backend</c> / <c>podman logs</c>),或者用 <c>MoShiro__ClaimCode</c> 自己指定。
|
||||
/// 创建完第一个管理员,认领码立刻作废,这个入口也就关闭了。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed partial class SetupService : IDisposable
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
private const int CodeLength = 16;
|
||||
|
||||
private readonly AuthService _auth;
|
||||
private readonly IAuthStore _store;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly ILogger<SetupService> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private string? _code;
|
||||
|
||||
public SetupService(AuthService auth, IAuthStore store, BackendOptions options, ILogger<SetupService> log)
|
||||
{
|
||||
_auth = auth;
|
||||
_store = store;
|
||||
_options = options;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Dispose() => _gate.Dispose();
|
||||
|
||||
/// <summary>面板里还没有任何用户。</summary>
|
||||
public async ValueTask<bool> NeedsSetupAsync(CancellationToken ct) => await _store.CountUsersAsync(ct) == 0;
|
||||
|
||||
/// <summary>启动时调用:需要初始化就准备好认领码,并写进日志(只有能看到日志的人才拿得到)。</summary>
|
||||
public async ValueTask AnnounceAsync(CancellationToken ct)
|
||||
{
|
||||
if (await NeedsSetupAsync(ct))
|
||||
{
|
||||
LogClaimCode(_log, _options.PublicUrl ?? "the panel's address", Code());
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<LoginResult>> CompleteAsync(string? claimCode, string username, string password, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!await NeedsSetupAsync(ct))
|
||||
{
|
||||
return Result<LoginResult>.Fail(MoShiroErrors.NameConflict, "the panel has already been claimed");
|
||||
}
|
||||
|
||||
if (claimCode is null || !AuthService.FixedTimeEquals(Normalize(claimCode), Normalize(Code())))
|
||||
{
|
||||
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "the claim code is not valid (see the backend's log)");
|
||||
}
|
||||
|
||||
Result<UserRecord> created = await _auth.CreateUserAsync(username, password, ct);
|
||||
if (!created.TryGet(out _, out FleetError? error))
|
||||
{
|
||||
return Result<LoginResult>.Fail(error);
|
||||
}
|
||||
|
||||
_code = null; // 一次有效:第一个管理员创建完,这个入口就关了
|
||||
return await _auth.LoginAsync(username, password, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string Code() => _code ??= string.IsNullOrWhiteSpace(_options.ClaimCode) ? Generate() : _options.ClaimCode.Trim();
|
||||
|
||||
private static string Generate()
|
||||
{
|
||||
char[] chars = new char[CodeLength];
|
||||
for (int i = 0; i < chars.Length; i++)
|
||||
{
|
||||
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
|
||||
}
|
||||
|
||||
string raw = new(chars);
|
||||
return string.Join('-', Enumerable.Range(0, CodeLength / 4).Select(g => raw.Substring(g * 4, 4)));
|
||||
}
|
||||
|
||||
/// <summary>比较时不分大小写、忽略分隔符和空格。</summary>
|
||||
private static string Normalize(string code) => new(code.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray());
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "First start: no administrator exists yet. Open {Address} and finish the setup there with this claim code: {Code}")]
|
||||
private static partial void LogClaimCode(ILogger logger, string address, string code);
|
||||
}
|
||||
@@ -8,16 +8,19 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary>
|
||||
public sealed record TrustProposal(string Statement, int Version, string Challenge);
|
||||
public sealed record TrustProposal(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredential> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||||
|
||||
/// <summary>下一版里新增的 passkey:签名的人要看清楚是不是自己加的,攻击者往账号里塞一把 passkey 再等人签名,是这个流程唯一的风险。</summary>
|
||||
public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs);
|
||||
|
||||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||||
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwner>? OperatorDetails = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||||
/// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链,
|
||||
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。
|
||||
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/security/access.md 第 8 节。
|
||||
/// </summary>
|
||||
public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
{
|
||||
@@ -53,7 +56,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
||||
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
||||
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true);
|
||||
// 每个操作员是哪个账户的哪把 passkey:添加节点时选属主、信任页都要显示
|
||||
var details = new List<NodeOwner>();
|
||||
foreach (OperatorKey key in current?.Keys ?? [])
|
||||
{
|
||||
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(key.CredentialId, ct);
|
||||
string? account = passkey is null ? null : (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
|
||||
details.Add(new NodeOwner(key.CredentialId, passkey?.Name, account));
|
||||
}
|
||||
|
||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true, details);
|
||||
}
|
||||
|
||||
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
||||
@@ -72,7 +84,12 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
[.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
|
||||
[_server.PublicKeySpki]);
|
||||
string statement = OperatorTrust.Encode(next);
|
||||
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))));
|
||||
HashSet<string> before = [.. current?.Keys.Select(k => k.CredentialId) ?? []];
|
||||
HashSet<string> after = [.. next.Keys.Select(k => k.CredentialId)];
|
||||
ProposedCredential[] added = [.. passkeys.Where(p => !before.Contains(p.Id)).Select(p => new ProposedCredential(p.Id, p.Name, p.CreatedAtMs))];
|
||||
string[] removed = [.. before.Where(id => !after.Contains(id)).Order(StringComparer.Ordinal)];
|
||||
bool serverChanged = current is not null && !(current.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((next.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
|
||||
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))), added, removed, serverChanged);
|
||||
}
|
||||
|
||||
private static bool Same(OperatorSet a, OperatorSet b) =>
|
||||
@@ -82,7 +99,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
||||
/// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。
|
||||
/// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。
|
||||
/// </summary>
|
||||
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeyS
|
||||
|
||||
/// <summary>
|
||||
/// 解析并校验 WebAuthn 注册(<c>navigator.credentials.create</c> 的结果)。只接受 ES256;attestation 一律当 <c>none</c> 处理:
|
||||
/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。
|
||||
/// 不验设备证明,信任浏览器的实现(docs/security/access.md)。CBOR 解析只在 Backend 里,节点不带。
|
||||
/// </summary>
|
||||
public static class WebAuthnRegistration
|
||||
{
|
||||
|
||||
@@ -21,6 +21,12 @@ public sealed class BackendOptions
|
||||
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
|
||||
public string? BackendUpdateStatusDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
|
||||
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
|
||||
/// </summary>
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
|
||||
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
|
||||
public string? PublicUrl { get; set; }
|
||||
|
||||
@@ -32,15 +38,6 @@ public sealed class BackendOptions
|
||||
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
|
||||
public string? WebAuthnOrigins { get; set; }
|
||||
|
||||
/// <summary>提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。</summary>
|
||||
public int ElevationEnrollMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>吊销和删除类的分钟数。</summary>
|
||||
public int ElevationRevokeMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>改凭证类(用一次就没有)的秒数。</summary>
|
||||
public int ElevationCredentialSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
||||
public bool SecureCookies { get; set; } = true;
|
||||
|
||||
@@ -106,6 +103,12 @@ public sealed class BackendOptions
|
||||
/// </summary>
|
||||
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
||||
|
||||
/// <summary>
|
||||
/// 首次认领的认领码(可选):面板里还没有任何用户时,第一个管理员由部署的人自己在页面上填,需要这个码才能创建。
|
||||
/// 不设置就在首次启动时随机生成并写进后端的日志。创建完第一个管理员后作废。
|
||||
/// </summary>
|
||||
public string? ClaimCode { get; set; }
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ public sealed class HotLayer
|
||||
|
||||
public void MarkReady() => _ready = true;
|
||||
|
||||
public IEnumerable<(NodeId Agent, string Series)> Keys => _rings.Keys;
|
||||
|
||||
/// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary>
|
||||
public void Merge(NodeId agent, string series, RawChunk chunk)
|
||||
{
|
||||
@@ -94,27 +92,6 @@ public sealed class HotLayer
|
||||
return result;
|
||||
}
|
||||
|
||||
public int BucketCount(NodeId agent, string series)
|
||||
{
|
||||
if (!_rings.TryGetValue((agent, series), out Ring? ring))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
lock (ring)
|
||||
{
|
||||
return ring.Buckets.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Remove(NodeId agent)
|
||||
{
|
||||
foreach ((NodeId Agent, string Series) key in _rings.Keys.Where(k => k.Agent == agent).ToList())
|
||||
{
|
||||
_rings.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Prune(Ring ring, long cutoff)
|
||||
{
|
||||
while (ring.Buckets.Count > 0)
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed record ChunkSignal(NodeId AgentId, string Series) : LiveSignal;
|
||||
public sealed record TestSignal(TestId TestId) : LiveSignal;
|
||||
|
||||
/// <summary>某个 Agent 的主机资源占用有了新的快照(实时推给展示页)。</summary>
|
||||
public sealed record HostSignal(NodeId AgentId, HostSnapshot Host) : LiveSignal;
|
||||
public sealed record HostSignal(NodeId Node, HostSnapshot Host) : LiveSignal;
|
||||
|
||||
public sealed class LiveSubscription : IDisposable
|
||||
{
|
||||
@@ -45,8 +45,6 @@ public sealed class LiveHub
|
||||
{
|
||||
private readonly ConcurrentDictionary<LiveSubscription, byte> _subscriptions = new();
|
||||
|
||||
public int SubscriberCount => _subscriptions.Count;
|
||||
|
||||
public LiveSubscription Subscribe()
|
||||
{
|
||||
var subscription = new LiveSubscription(this);
|
||||
|
||||
@@ -66,6 +66,17 @@ public static class IspClassifier
|
||||
return (Other, Shorten(org));
|
||||
}
|
||||
|
||||
/// <summary>手动指定运营商时的中文名;不认识的取值返回 null。</summary>
|
||||
public static string? NameOf(string key) => key switch
|
||||
{
|
||||
Telecom => "中国电信",
|
||||
Unicom => "中国联通",
|
||||
Mobile => "中国移动",
|
||||
Broadnet => "中国广电",
|
||||
Other => "其他",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
|
||||
|
||||
@@ -16,7 +16,8 @@ public sealed record NodeInfo(
|
||||
string? Version = null,
|
||||
string? Tag = null,
|
||||
string? Commit = null,
|
||||
string? BuiltAt = null);
|
||||
string? BuiltAt = null,
|
||||
bool IspManual = false);
|
||||
|
||||
/// <summary>
|
||||
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
|
||||
|
||||
@@ -42,6 +42,31 @@ public sealed class NodeInfoService
|
||||
return [.. _cache.Values.Select(m => m.Info)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动指定节点的运营商(telecom / unicom / mobile / broadnet / other);传 null 回到自动识别(下次节点出现时重新查)。
|
||||
/// 返回 false 表示取值不认识。
|
||||
/// </summary>
|
||||
public async ValueTask<bool> SetIspAsync(string nodeId, string kind, string? key, CancellationToken ct)
|
||||
{
|
||||
await EnsureLoadedAsync(ct);
|
||||
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
|
||||
if (key is null)
|
||||
{
|
||||
// 清掉手动值,并让下一次心跳重新查询
|
||||
await SaveAsync(current with { Info = current.Info with { IspKey = null, IspName = null, IspManual = false }, GeoHost = null, GeoAtMs = 0, UpdatedAtMs = Now() }, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
string? name = IspClassifier.NameOf(key);
|
||||
if (name is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await SaveAsync(current with { Info = current.Info with { IspKey = key, IspName = name, IspManual = true }, UpdatedAtMs = Now() }, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
|
||||
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
|
||||
{
|
||||
@@ -101,8 +126,15 @@ public sealed class NodeInfoService
|
||||
return;
|
||||
}
|
||||
|
||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
||||
string country = geo.Country.ToUpperInvariant();
|
||||
if (current.Info.IspManual)
|
||||
{
|
||||
// 运营商是手动指定的:只更新国家,不覆盖运营商
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -422,8 +422,4 @@ public sealed class QueryService
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/// <summary>从 JSON 文本读取参数(供端点之外的调用方使用)。</summary>
|
||||
public static Dictionary<string, JsonElement> ParseParams(string json) =>
|
||||
JsonSerializer.Deserialize(json, TestJson.Default.ParamMap) ?? [];
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。
|
||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、(用配置里的凭据创建管理员,没有的话把首次认领码写进日志)创建默认的线路任务模板。
|
||||
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
|
||||
/// </summary>
|
||||
public sealed class StartupTasks : IHostedService
|
||||
@@ -12,9 +12,11 @@ public sealed class StartupTasks : IHostedService
|
||||
private readonly AuthService _auth;
|
||||
private readonly TaskService _tasks;
|
||||
private readonly ServerIdentityService _identity;
|
||||
private readonly SetupService _setup;
|
||||
|
||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity)
|
||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity, SetupService setup)
|
||||
{
|
||||
_setup = setup;
|
||||
_identity = identity;
|
||||
_raw = raw;
|
||||
_auth = auth;
|
||||
@@ -25,6 +27,7 @@ public sealed class StartupTasks : IHostedService
|
||||
{
|
||||
await _raw.RecoverAsync(cancellationToken);
|
||||
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
||||
await _setup.AnnounceAsync(cancellationToken);
|
||||
await _tasks.SeedDefaultsAsync(cancellationToken);
|
||||
await _identity.EnsureAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>CI 发布公钥(PEM):验登记的发布清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
|
||||
public interface IReleaseKey
|
||||
{
|
||||
string Pem { get; }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 镜像仓库里的签名清单:CI 每次构建都把签名清单放在 <c><命名空间>/moshiro-<组件>-manifest:<通道></c> 这个没有文件层的小镜像的标签里
|
||||
/// (<c>org.moshiro.manifest</c> 是 base64 的清单,<c>org.moshiro.signature</c> 是签名)。面板没有登记过这个组件的清单时(清过库、CI 登记那一步网络失败),
|
||||
/// 直接按 OCI 分发协议匿名读取它——只读两个小 JSON,不拉任何镜像层。取回来的只是候选:调用方要用 CI 的发布公钥验过签才采用,所以仓库被改也只会验签失败。
|
||||
/// </summary>
|
||||
public sealed partial class ReleaseFeed : IDisposable
|
||||
{
|
||||
private const int MaxBytes = 1 << 20;
|
||||
|
||||
private readonly BackendOptions _options;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public ReleaseFeed(BackendOptions options)
|
||||
{
|
||||
_options = options;
|
||||
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
|
||||
/// <summary>取不到(仓库不可读、没有这个标签、格式不对)返回 null;不抛异常。</summary>
|
||||
public async ValueTask<(string Manifest, string Signature)?> FetchAsync(string component, string channel, CancellationToken ct)
|
||||
{
|
||||
string registry = (_options.NodeImageRegistry ?? "").Trim().TrimEnd('/');
|
||||
if (registry.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string baseUrl = registry.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || registry.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? registry : "https://" + registry;
|
||||
string repository = $"{(channel == "dev" ? "moshiro-dev" : "moshiro")}/moshiro-{component}-manifest";
|
||||
try
|
||||
{
|
||||
string manifestJson = await GetAsync($"{baseUrl}/v2/{repository}/manifests/{channel}", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json", ct);
|
||||
using JsonDocument manifest = JsonDocument.Parse(manifestJson);
|
||||
string? digest = manifest.RootElement.GetProperty("config").GetProperty("digest").GetString();
|
||||
if (digest is null || !Digest().IsMatch(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string configJson = await GetAsync($"{baseUrl}/v2/{repository}/blobs/{digest}", "application/json", ct);
|
||||
using JsonDocument config = JsonDocument.Parse(configJson);
|
||||
JsonElement labels = config.RootElement.GetProperty("config").GetProperty("Labels");
|
||||
string? encoded = labels.GetProperty("org.moshiro.manifest").GetString();
|
||||
string? signature = labels.GetProperty("org.moshiro.signature").GetString();
|
||||
return encoded is null || signature is null ? null : (Encoding.UTF8.GetString(Convert.FromBase64String(encoded)), signature);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetAsync(string url, string accept, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.TryAddWithoutValidation("Accept", accept);
|
||||
using HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength is > MaxBytes)
|
||||
{
|
||||
throw new InvalidOperationException("the registry response is too large");
|
||||
}
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using var limited = new MemoryStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
limited.Write(buffer, 0, read);
|
||||
if (limited.Length > MaxBytes)
|
||||
{
|
||||
throw new InvalidOperationException("the registry response is too large");
|
||||
}
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(limited.ToArray());
|
||||
}
|
||||
|
||||
[GeneratedRegex("^sha256:[0-9a-f]{64}$")]
|
||||
private static partial Regex Digest();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Security.Cryptography;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>一把发布公钥的指纹(SPKI 的 SHA-256,小写十六进制)和 PEM。</summary>
|
||||
public sealed record ReleaseKeyInfo(string Fingerprint, string PublicKey);
|
||||
|
||||
/// <summary>可信资产来源:官方 CI 发布密钥(内嵌,始终信任)和可选的自有发布公钥(默认没有)。</summary>
|
||||
public sealed record ReleaseKeys(ReleaseKeyInfo Official, ReleaseKeyInfo? Custom);
|
||||
|
||||
/// <summary>
|
||||
/// 面板信任哪些发布密钥:内嵌的官方 CI 公钥始终信任,不能关;部署的人可以另外导入<b>自己的发布公钥</b>(默认没有),
|
||||
/// 用自己的私钥给自己构建的清单签名。验签(登记清单、取镜像仓库里的清单)通过官方或自有任意一把即可。
|
||||
/// 私钥只在部署的人手里,面板只存公钥,拒绝带私钥的 PEM。
|
||||
/// </summary>
|
||||
public sealed class ReleaseKeyService
|
||||
{
|
||||
internal const string SettingKey = "release.custom_key";
|
||||
private const int MaxPemBytes = 1024;
|
||||
private const string P256Oid = "1.2.840.10045.3.1.7";
|
||||
|
||||
private readonly IReleaseKey _official;
|
||||
private readonly ISettingsStore _settings;
|
||||
|
||||
public ReleaseKeyService(IReleaseKey official, ISettingsStore settings)
|
||||
{
|
||||
_official = official;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async ValueTask<ReleaseKeys> GetAsync(CancellationToken ct) =>
|
||||
new(Describe(_official.Pem)!, Describe(await CustomAsync(ct)));
|
||||
|
||||
/// <summary>清单的签名由官方密钥或自有密钥(开启时)验过。</summary>
|
||||
public async ValueTask<bool> VerifyAsync(string manifest, string signature, CancellationToken ct) =>
|
||||
ReleaseSignature.Verify(_official.Pem, manifest, signature)
|
||||
|| (await CustomAsync(ct) is { } custom && ReleaseSignature.Verify(custom, manifest, signature));
|
||||
|
||||
public async ValueTask<Result<ReleaseKeys>> SetCustomAsync(string? publicKey, CancellationToken ct)
|
||||
{
|
||||
if (!TryNormalize(publicKey, out string? pem, out string? problem))
|
||||
{
|
||||
return ValidationError.Fail<ReleaseKeys>("public_key", problem);
|
||||
}
|
||||
|
||||
await _settings.SetAsync(SettingKey, pem, ct);
|
||||
return Result<ReleaseKeys>.Ok(await GetAsync(ct));
|
||||
}
|
||||
|
||||
public async ValueTask ClearCustomAsync(CancellationToken ct) => await _settings.SetAsync(SettingKey, string.Empty, ct);
|
||||
|
||||
private async ValueTask<string?> CustomAsync(CancellationToken ct) =>
|
||||
await _settings.GetAsync(SettingKey, ct) is { Length: > 0 } pem ? pem : null;
|
||||
|
||||
private static ReleaseKeyInfo? Describe(string? pem)
|
||||
{
|
||||
if (pem is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(pem);
|
||||
return new ReleaseKeyInfo(Convert.ToHexStringLower(SHA256.HashData(key.ExportSubjectPublicKeyInfo())), key.ExportSubjectPublicKeyInfoPem().Trim());
|
||||
}
|
||||
|
||||
/// <summary>只收 P-256 的公钥:带私钥的 PEM 直接拒绝(私钥不该离开你的机器)。</summary>
|
||||
private static bool TryNormalize(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? pem, [System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? problem)
|
||||
{
|
||||
pem = null;
|
||||
problem = null;
|
||||
string trimmed = (text ?? string.Empty).Trim();
|
||||
if (trimmed.Length is 0 or > MaxPemBytes)
|
||||
{
|
||||
problem = "must be a PEM public key";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed.Contains("PRIVATE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
problem = "must be a public key: never upload the private key, keep it on your own machine";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(trimmed);
|
||||
if (key.ExportParameters(false).Curve.Oid.Value != P256Oid)
|
||||
{
|
||||
problem = "must be an ECDSA P-256 public key";
|
||||
return false;
|
||||
}
|
||||
|
||||
pem = key.ExportSubjectPublicKeyInfoPem().Trim();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
problem = "is not a valid PEM public key";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定时去镜像仓库取各组件(Agent、Probe、Backend 自己)最新的签名清单:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告),
|
||||
/// 面板自己也去仓库取,Backend 的更新通知和节点的更新建议就不依赖那一步。取回来的清单要用发布公钥验过签才登记。
|
||||
/// </summary>
|
||||
public sealed partial class ReleasePoller : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan s_startupDelay = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan s_interval = TimeSpan.FromMinutes(5);
|
||||
private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe, ReleaseService.BackendComponent];
|
||||
|
||||
private readonly ReleaseService _releases;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<ReleasePoller> _log;
|
||||
|
||||
public ReleasePoller(ReleaseService releases, TimeProvider time, ILogger<ReleasePoller> log)
|
||||
{
|
||||
_releases = releases;
|
||||
_time = time;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(s_startupDelay, _time, stoppingToken);
|
||||
using var timer = new PeriodicTimer(s_interval, _time);
|
||||
do
|
||||
{
|
||||
foreach (string component in s_components)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _releases.GetOrFetchAsync(component, stoppingToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogFailed(ex, component);
|
||||
}
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 后端正在停止
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "could not check the registry for the latest {Component} release")]
|
||||
private partial void LogFailed(Exception exception, string component);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public sealed record ReleaseOffer(string Component, long Seq, string Tag, string
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
|
||||
/// <b>面板不验签、也不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 登记入口(<c>/api/release/offer</c>)只收带有效 CI 签名的清单;面板<b>不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
|
||||
/// </summary>
|
||||
public sealed partial class ReleaseService : IDisposable
|
||||
@@ -26,10 +26,15 @@ public sealed partial class ReleaseService : IDisposable
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly IBackendUpdateFiles _backendFiles;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly ReleaseKeyService _keys;
|
||||
private readonly ReleaseFeed _feed;
|
||||
private readonly ConcurrentDictionary<string, long> _feedChecked = new();
|
||||
private readonly ConcurrentDictionary<string, UpdateStatus> _status = new();
|
||||
|
||||
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options)
|
||||
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed)
|
||||
{
|
||||
_keys = keys;
|
||||
_feed = feed;
|
||||
_settings = settings;
|
||||
_gateway = gateway;
|
||||
_backendFiles = backendFiles;
|
||||
@@ -40,7 +45,7 @@ public sealed partial class ReleaseService : IDisposable
|
||||
|
||||
public void Dispose() => BackendGate.Dispose();
|
||||
|
||||
/// <summary>登记一份新清单并推给在线的同类节点。只要格式对、<c>seq</c> 比现有的新就收下(签名由节点验证)。</summary>
|
||||
/// <summary>登记一份新清单并推给在线的同类节点。格式对、带 CI 发布密钥的有效签名、<c>seq</c> 比现有的新才收下;所有登记入口(CI、镜像仓库、管理员)都走这里,所以没有不验签的路径。节点的更新器之后还会用本机公钥再验一次。</summary>
|
||||
public async ValueTask<Result<ReleaseOffer>> PublishAsync(string manifest, string signature, CancellationToken ct)
|
||||
{
|
||||
if (!TryParse(manifest, signature, out ReleaseOffer? offer, out string? problem))
|
||||
@@ -48,6 +53,11 @@ public sealed partial class ReleaseService : IDisposable
|
||||
return ValidationError.Fail<ReleaseOffer>("manifest", problem);
|
||||
}
|
||||
|
||||
if (!await _keys.VerifyAsync(manifest, signature, ct))
|
||||
{
|
||||
return Result<ReleaseOffer>.Fail(MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
|
||||
}
|
||||
|
||||
await BackendGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
@@ -84,6 +94,49 @@ public sealed partial class ReleaseService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 这个组件的清单;面板里没有(清过库、CI 登记那一步网络失败)或者已经放了一阵,就去镜像仓库里取一次最新的:
|
||||
/// 取回来的清单要用 CI 的发布公钥验过签才登记,所以仓库被改也只会被拒绝。取不到就用手里有的。
|
||||
/// </summary>
|
||||
public async ValueTask<ReleaseOffer?> GetOrFetchAsync(string component, CancellationToken ct)
|
||||
{
|
||||
string channel = BackendUpdates.ChannelOf(_options);
|
||||
ReleaseOffer? stored = component == BackendComponent ? await GetBackendAsync(channel, ct) : await GetAsync(component, ct);
|
||||
long now = Environment.TickCount64;
|
||||
long wait = stored is null ? 30_000 : 240_000; // 没有就 30 秒试一次,已经有了就隔 4 分钟看一眼有没有更新的(后台每 5 分钟问一次)
|
||||
if (_feedChecked.TryGetValue(component, out long last) && now - last < wait)
|
||||
{
|
||||
return stored;
|
||||
}
|
||||
|
||||
_feedChecked[component] = now;
|
||||
if (await _feed.FetchAsync(component, channel, ct) is { } fetched && await _keys.VerifyAsync(fetched.Manifest, fetched.Signature, ct))
|
||||
{
|
||||
Result<ReleaseOffer> published = await PublishAsync(fetched.Manifest, fetched.Signature, ct);
|
||||
if (published.IsSuccess)
|
||||
{
|
||||
return published.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/// <summary>不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。</summary>
|
||||
public async ValueTask PurgeUnverifiedAsync(CancellationToken ct)
|
||||
{
|
||||
foreach (string key in new[] { NodeKinds.Agent, NodeKinds.Probe, BackendComponent + ".dev", BackendComponent + ".release" })
|
||||
{
|
||||
string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct);
|
||||
string? signature = await _settings.GetAsync(Key(key, "signature"), ct);
|
||||
if (!string.IsNullOrEmpty(manifest) && !string.IsNullOrEmpty(signature) && !await _keys.VerifyAsync(manifest, signature, ct))
|
||||
{
|
||||
await _settings.SetAsync(Key(key, "manifest"), string.Empty, ct);
|
||||
await _settings.SetAsync(Key(key, "signature"), string.Empty, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
|
||||
{
|
||||
string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct);
|
||||
@@ -141,11 +194,9 @@ public sealed partial class ReleaseService : IDisposable
|
||||
|
||||
public UpdateStatus? StatusOf(NodeId node) => _status.TryGetValue(node.Value, out UpdateStatus? status) ? status : null;
|
||||
|
||||
public void Forget(NodeId node) => _status.TryRemove(node.Value, out _);
|
||||
|
||||
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
|
||||
|
||||
private static string? ComponentOf(NodeId node) =>
|
||||
internal static string? ComponentOf(NodeId node) =>
|
||||
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent
|
||||
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。
|
||||
/// 登记清单因此不需要任何令牌:能被登记的只有 CI 签过的清单,节点的更新器之后还会用本机的公钥再验一遍。
|
||||
/// </summary>
|
||||
public static class ReleaseSignature
|
||||
{
|
||||
public static bool Verify(string publicKeyPem, string manifest, string signatureBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(publicKeyPem);
|
||||
return key.VerifyData(Encoding.UTF8.GetBytes(manifest), Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 13;
|
||||
public const int CurrentVersion = 16;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -221,6 +221,44 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 13");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 14)
|
||||
{
|
||||
// 节点的二进制绑定:第一次握手成功时绑定正在运行的二进制的 SHA-256
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec(
|
||||
"""
|
||||
CREATE TABLE node_bindings (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
signed INTEGER NOT NULL,
|
||||
bound_at INTEGER NOT NULL,
|
||||
violation TEXT,
|
||||
violation_at INTEGER
|
||||
)
|
||||
""");
|
||||
connection.Exec("PRAGMA user_version = 14");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 15)
|
||||
{
|
||||
// 运行时二进制绑定整体移除(节点自报的哈希不可信,来源证明在安装时):旧版本留下的表不再使用
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("DROP TABLE IF EXISTS node_bindings");
|
||||
connection.Exec("PRAGMA user_version = 15");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 16)
|
||||
{
|
||||
// 运营商可以手动指定(自动识别会错,例如 CN2 线路):手动的不会被后面的 IP 归属查询覆盖
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN isp_manual INTEGER NOT NULL DEFAULT 0");
|
||||
connection.Exec("PRAGMA user_version = 16");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,20 +14,20 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<NodeMeta>>(
|
||||
c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at FROM node_meta", Read),
|
||||
c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual FROM node_meta", Read),
|
||||
ct);
|
||||
|
||||
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(
|
||||
c => c.Exec(
|
||||
"INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at) " +
|
||||
"VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt) ON CONFLICT(node_id) DO UPDATE SET " +
|
||||
"INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual) " +
|
||||
"VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt, @im) ON CONFLICT(node_id) DO UPDATE SET " +
|
||||
"kind = excluded.kind, os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " +
|
||||
"isp_key = excluded.isp_key, isp_name = excluded.isp_name, geo_host = excluded.geo_host, geo_at = excluded.geo_at, updated_at = excluded.updated_at, " +
|
||||
"version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at",
|
||||
"version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at, isp_manual = excluded.isp_manual",
|
||||
("@id", meta.Info.NodeId), ("@kind", meta.Info.Kind), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country),
|
||||
("@ik", meta.Info.IspKey), ("@in", meta.Info.IspName), ("@gh", meta.GeoHost), ("@ga", meta.GeoAtMs), ("@ua", meta.UpdatedAtMs),
|
||||
("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt)),
|
||||
("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt), ("@im", meta.Info.IspManual ? 1 : 0)),
|
||||
ct);
|
||||
|
||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct) =>
|
||||
@@ -36,6 +36,6 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
|
||||
private static NodeMeta Read(SqliteDataReader r)
|
||||
{
|
||||
static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i);
|
||||
return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14)), S(r, 8), r.GetInt64(9), r.GetInt64(10));
|
||||
return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14), r.GetInt64(15) != 0), S(r, 8), r.GetInt64(9), r.GetInt64(10));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public static class BackendHost
|
||||
public const string ConfigurationSection = "MoShiro";
|
||||
public const int MaxRequestBodyBytes = 64 * 1024;
|
||||
|
||||
public static WebApplication Build(string[] args, Action<BackendOptions>? configureOptions = null, TimeProvider? time = null)
|
||||
public static WebApplication Build(string[] args, Action<BackendOptions>? configureOptions = null, TimeProvider? time = null, Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
@@ -51,6 +51,7 @@ public static class BackendHost
|
||||
services.AddMoShiroApplication(options);
|
||||
services.AddMoShiroGateway();
|
||||
services.AddMoShiroApi();
|
||||
configureServices?.Invoke(services); // 测试替换发布公钥这类服务
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
// 静默死亡的 Agent(断电、断网)不会关闭连接:15 秒一个 Ping,20 秒没有 Pong 就关闭,这样面板上的在线状态才可信
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Xml.Linq;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 依赖规则(docs/project-layout.md 第 3 节):由测试在构建期强制。
|
||||
/// 依赖规则(docs/dev/project-layout.md 第 3 节):由测试在构建期强制。
|
||||
/// 读取仓库里所有 .csproj 和源文件,违反规则就失败。
|
||||
/// </summary>
|
||||
public sealed class ArchitectureTests
|
||||
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user