30 Commits
Author SHA1 Message Date
justme 725fe17a7d chore: release 0.4.0 [CI]
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/tag/linux-release Pipeline was successful
ci/woodpecker/tag/gitea-release Pipeline was successful
2026-10-09 03:54:56 +08:00
justme e49990776b feat(display): falling snow in three depth layers with wind, mouse and bullet time; appearance panel; dark glass tinted from the background [CI SKIP] 2026-10-09 03:54:49 +08:00
justme f8e8bb8acb feat(display): pointer-sway background with a dark variant; acrylic glass tokens [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 03:09:14 +08:00
justme 70f20d38d0 feat(display): site name and avatar in the top bar, content aligned left [CI SKIP] 2026-10-09 03:08:54 +08:00
justme 7ff03a9628 fix(display): a hovered card no longer covers the top bar [CI SKIP] 2026-10-09 03:08:53 +08:00
justme 5dce387c87 ci: build a dev image to test automatic node updates [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 02:10:38 +08:00
justme 535bb3517d feat: node updates by restart, auto by default; Podman Quadlet install from the panel alongside compose [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 01:33:22 +08:00
justme 88b1a0a9bb feat: probe host stats, node cards with a separate lines panel grouped by carrier, Windows host sampling, manual carrier override [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 21:56:19 +08:00
justme bac1c27e72 fix: compose passes .env through env_file; clearer name and channel messages [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
podman-compose does not substitute defaults inside "environment:" (a value
like ${MOSHIRO_NAME:-} reached the node as the literal text), so the node was
enrolled with an invalid name and reported the wrong network scope. The three
compose files now read .env with env_file and only interpolate image and
network_mode; the Backend .env keys are the container's own names
(MoShiro__PublicUrl, MoShiro__ClaimCode).

- The generated .env no longer carries a comment line for MOSHIRO_NAME; the
  dialog says how to choose a name instead.
- The container entrypoint prints the name it enrolls with and defaults
  MOSHIRO_NETWORK_SCOPE to host (Docker Desktop sets container in .env).
- The overview and line pages say "measurement channel not established"
  instead of "not connected": the agent can be reachable and pingable while
  the measurement TCP/UDP path is not.
2026-10-08 20:47:38 +08:00
justme 12be44645f feat: install nodes and the backend from a static compose file; remove dead code [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Agent, Probe and Backend now start from one static compose.yaml plus a local
.env: the panel (or the release assets, for the very first Backend) provides
the file, the panel's command writes the public values into .env, checks the
file's SHA-256 and runs compose up. Updating is compose pull && compose up -d.
The image is chosen by the CI channel (:latest, or MOSHIRO_VERSION in .env),
not by the panel; the previous one-line script stays as a fallback.

- packaging/compose/{agent,probe,backend}/compose.yaml are the single source,
  embedded in the Backend and served at /install/compose/{file}; the root
  compose.yaml moved there. Releases also carry them and list them in the
  signed SHA256SUMS.
- The container entrypoint defaults the node name to the host name.
- Join token creation returns a compose block (.env, url, hash, per-platform
  commands); the admin dialog leads with it.
- Remove code with no references (HotLayer members, LiveHub.SubscriberCount,
  QueryService.ParseParams, ReleaseService.Forget, a few constants) and reduce
  the install-code lookup to the role it is for.
2026-10-08 20:24:23 +08:00
justme e014a19554 docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link,
  code comment and the protocol conformance test that read docs/protocol.md.
- access.md and security.md rewritten to what is implemented: removed the
  unadopted member-proof chain, roles and recovery key that do not exist,
  the old implementation order and the budgets; gaps now carry a status.
- enrollment, deployment, api: the install flow is the static, hash-checked
  script with environment variables; no default admin.
- README and architecture: new topology and update-flow diagrams (operator
  passkey, CI signing, registry feed, auth and trust modules).
2026-10-08 19:37:56 +08:00
justme 636a784043 fix: the panel polls the registry for its own and the nodes' releases [CI]
ci/woodpecker/push/main-amd64-build Pipeline is running
Registering a manifest with the panel is best effort in CI (an unreachable
panel only warns, the pipeline stays green). The Backend update notice then
never appeared, because the host updater only reads the mailbox the panel
writes. A background poller now fetches the signed manifests of the agent, the
probe and the backend itself from the registry every five minutes, verifies the
release signature and registers them, writing the backend one into the host
updater's mailbox.
2026-10-08 19:09:21 +08:00
justme 0f8f3f9a54 feat: trusted release keys setting; drop the duplicate release registration route [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
The panel now lets the deployer decide which release keys it trusts. The
official CI key is built in and always trusted; an own P-256 public key can be
added (off by default, the private key is rejected) so manifests signed with it
are accepted too. Removing it drops the manifests only it signed. Changes are
audited and need update.confirm.

Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on
generating a key pair and signing a manifest with openssl.

Registration now has one path, POST /api/release/offer (no token, signature
verified). The admin POST /releases route, the release.publish capability and
the ci-release preset are removed.
2026-10-08 18:50:16 +08:00
justme 904ea2b0ae refactor: remove the runtime binary binding; every release registration path verifies the CI signature [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
The hash a node reports about itself cannot be trusted (a swapped binary can
copy the old hash), so the runtime binding only caught files that were replaced
by something that did not lie. Provenance stays where it can be verified: the
signed release manifest (digest) and the signed SHA256SUMS.

- Remove BinaryBindingService, the node_bindings table (schema v15 drops it),
  BinaryPolicy, the handshake hash/manifest fields and the admin binary column.
- Drop the per-platform "binaries" list from the release manifest, the hash
  scripts and the updater's installed.manifest copy.
- ReleaseService.PublishAsync now verifies the release signature, so the admin
  POST /releases route is no longer an unsigned path; tests sign with a test key.
- Docs: section 16 explains why the runtime check is gone.
2026-10-08 18:30:05 +08:00
justme a4f3d1892a ci: all-platform node binaries in the signed release manifest; signed SHA256SUMS for the archives; web build once before the parallel backend builds; release 0.3.3 [CI SKIP]
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/tag/linux-release Pipeline was successful
ci/woodpecker/tag/gitea-release Pipeline was successful
2026-10-08 16:26:29 +08:00
justme cab505b837 fix(ci): back to building the web apps inside the backend build; the two architectures run one after the other; release 0.3.2 [CI SKIP]
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/tag/linux-release Pipeline was successful
ci/woodpecker/tag/gitea-release Pipeline was successful
2026-10-08 16:12:59 +08:00
justme cd9e677331 fix(ci): build the web apps once before the two backend architectures; release 0.3.1 [CI]
ci/woodpecker/push/main-amd64-build Pipeline is running
ci/woodpecker/tag/linux-release Pipeline is running
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/tag/gitea-release unknown status
2026-10-08 16:05:38 +08:00
justme 879d1c8593 fix: installer checks containers only; the panel reads signed manifests from the registry when it has none; unsigned-updater nodes are labeled from the panel's manifests; sign node release images; release 0.3.0 [CI]
ci/woodpecker/push/main-amd64-build Pipeline was canceled
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/tag/gitea-release unknown status
2026-10-08 15:59:28 +08:00
justme 4a1e48db20 test: share one operator and trust root across the trust tests; make the setup test cleanup best-effort [CI SKIP] 2026-10-08 15:30:12 +08:00
justme f22fdf8b68 feat: first-run claim wizard (no default admin, claim code from the backend log); owner list shows account and passkey; container install first [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 06:47:10 +08:00
justme 2ae63653c4 feat: the node itself requires an owner passkey (shared by agent and probe) and reports it; the panel derives the account and passkey it is bound to [CI] 2026-10-08 06:15:07 +08:00
justme 8c37db1577 feat: every agent and probe must have an owner passkey, enforced by the panel, the installers and fleet-enroll [CI] 2026-10-08 06:04:04 +08:00
justme 0b384a58f5 fix: hash the node binary without the registry auth file that does not exist yet [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 05:43:51 +08:00
justme 4841f7b15b feat: bind each node to the binary it runs at its first handshake; refuse changes that are not in a CI-signed manifest [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
2026-10-08 05:39:13 +08:00
justme 117d34a2af feat: signed manifests live in the image registry; updaters and first deploys fetch and verify them without the panel [CI] 2026-10-08 05:05:13 +08:00
justme 4106f831dc feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI] 2026-10-08 04:57:28 +08:00
justme f704c66dd9 feat: CI registers release manifests without a token; the panel verifies the CI signature instead [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
2026-10-08 04:22:01 +08:00
justme fbda9487d5 feat: serve the web apps from the Backend (embedded, allow-listed, no filesystem access) so passkeys bind to the panel's own domain [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
2026-10-08 04:09:40 +08:00
justme 4a3631f82b fix: gate password sessions on the login method so data-source sessions also get the passkey-only page [CI SKIP] 2026-10-08 04:09:39 +08:00
justme 3b88a3419d ci: build the dev images for the genesis-pinned trust model [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 03:28:03 +08:00
195 changed files with 7758 additions and 3226 deletions

No files matched your search

+5
View File
@@ -27,6 +27,9 @@ steps:
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
from_secret: zot_secret from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands: commands:
- bash ci/release-node-images.sh - bash ci/release-node-images.sh
depends_on: [] depends_on: []
@@ -37,6 +40,8 @@ steps:
GITEA_BASE: http://127.0.0.1:3100 GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands: commands:
- bash ci/publish-gitea-release.sh - bash ci/publish-gitea-release.sh
depends_on: depends_on:
+15 -4
View File
@@ -15,6 +15,17 @@ when:
concurrency: 1 concurrency: 1
steps: 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 - name: linux-amd64-backend
image: bash image: bash
environment: environment:
@@ -25,7 +36,8 @@ steps:
from_secret: zot_secret from_secret: zot_secret
commands: commands:
- bash ci/release-linux.sh amd64 backend - bash ci/release-linux.sh amd64 backend
depends_on: [] depends_on:
- web-build
- name: linux-arm64-backend - name: linux-arm64-backend
image: bash image: bash
@@ -37,7 +49,8 @@ steps:
from_secret: zot_secret from_secret: zot_secret
commands: commands:
- bash ci/release-linux.sh arm64 backend - bash ci/release-linux.sh arm64 backend
depends_on: [] depends_on:
- web-build
- name: backend-multiarch-index - name: backend-multiarch-index
image: bash image: bash
@@ -50,8 +63,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY: RELEASE_SIGNING_KEY:
from_secret: release_signing_key from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands: commands:
- bash ci/index-backend-image.sh - bash ci/index-backend-image.sh
depends_on: depends_on:
-6
View File
@@ -27,8 +27,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY: RELEASE_SIGNING_KEY:
from_secret: release_signing_key from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands: commands:
- bash ci/build-main-amd64.sh backend - bash ci/build-main-amd64.sh backend
depends_on: [] depends_on: []
@@ -44,8 +42,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY: RELEASE_SIGNING_KEY:
from_secret: release_signing_key from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands: commands:
- bash ci/build-main-amd64.sh agent - bash ci/build-main-amd64.sh agent
depends_on: [] depends_on: []
@@ -61,8 +57,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY: RELEASE_SIGNING_KEY:
from_secret: release_signing_key from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands: commands:
- bash ci/build-main-amd64.sh probe - bash ci/build-main-amd64.sh probe
depends_on: [] depends_on: []
+2 -2
View File
@@ -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. - 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. - 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. - 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`. - 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 ## 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 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. 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.
+36 -30
View File
@@ -12,45 +12,54 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
**它不做什么** **它不做什么**
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。 - **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。 - **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。 - **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
- 暂不包含告警(见文末待办)。 - 暂不包含告警(见文末待办)。
## 信任关系 ## 信任关系
```text ```text
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend 管理员 ── 密码或 passkey(Cookie 会话 + CSRF),或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口 访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
操作员 ── passkey 给「操作员集合」签名 ──▶ Backend 只转发 ──▶ 节点自己验链
(创世指纹和属主在安装时固定在节点上;集合里还登记了 Backend 的服务器密钥,节点据此认 Backend、加密链路)
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥) 节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥) Probe ── 先敲门,再用自己的密钥签名建立测量会话 ───────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签 CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ 镜像仓库 / Backend ──▶ 节点宿主机的更新器用本机公钥验签
``` ```
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。 - **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。 - **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。 - **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。
- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。
- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。
## 架构 ## 架构
```text ```text
Web(展示端 · 管理端) 操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像)
▲ REST + SSE ▼ ▲
┌──────────────────────────────────────────────────────────┐ Web(展示端 · 管理端) │ 定时取清单
│ Backend(面板) │ ▲ REST + SSE │
│ Api · Registry · Planner · Links │ ┌──────────────────────────────────────────────────────────────┐
│ Releases · Analysis · LiveHub │ │ Backend(面板) │──────────────────────────────┘
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │
│ Releases(发布清单 · 可信发布密钥) │
│ Ingest · Analysis · LiveHub │
│ Store(段文件 + SQLite) │ │ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ wss 长连接(节点发起) │ wss 长连接(节点发起) │ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知 │ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐ ┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │ │ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │ │ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘ └────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出 测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
``` ```
两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。 两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
@@ -60,7 +69,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web 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 映射变化后自动跟随。 - **测量数据面**: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`,前端据此生成类型。 - **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 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 # 后端改了接口之后重新生成前端类型 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/README.md`](docs/README.md) | 文档索引,按类别组织 |
| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 | | [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 |
| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 | | [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) | | [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 | | [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 | | [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 |
| [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 | | [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) |
| [`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) | 仓库结构与依赖规则、代码风格 |
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。 代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
+4 -4
View File
@@ -12,12 +12,12 @@
## 任务与面板 ## 任务与面板
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务) - 单个 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 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。 - 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
[security.md](docs/security.md) [security.md](docs/security/security.md)
## 访客页面 ## 访客页面
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。 - 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
@@ -34,11 +34,11 @@
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。 - 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。 - 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。 - 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) 的项目边界。 - **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。 - 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
+1 -1
View File
@@ -13,7 +13,7 @@
<UseArtifactsOutput>true</UseArtifactsOutput> <UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath> <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<Version>0.2.0</Version> <Version>0.4.0</Version>
<NuGetAudit>false</NuGetAudit> <NuGetAudit>false</NuGetAudit>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+3 -3
View File
@@ -36,7 +36,7 @@ dotnet test --solution MoShiro.slnx
dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true 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 镜像中。 正式发布通过 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 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)。
+4 -4
View File
@@ -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 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 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。 不传 `--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 断开时对应的会话资源会释放。 - 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。 - 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 - 测量引擎和协议由 `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。首次导入和自动更新见部署文档。
+6 -5
View File
@@ -31,7 +31,7 @@ public sealed class AgentApp : IDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission; private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20); private readonly Outbox _outbox = new(64 << 20);
private readonly UpdateBridge _update; private readonly RestartUpdater? _update;
private readonly BackendUplink _uplink; private readonly BackendUplink _uplink;
private readonly UdpMux _udp; private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp; private readonly TcpAcceptor _tcp;
@@ -46,7 +46,7 @@ public sealed class AgentApp : IDisposable
{ {
_ownedRestart = restart is null ? new RestartController() : null; _ownedRestart = restart is null ? new RestartController() : null;
_restart = restart ?? _ownedRestart!; _restart = restart ?? _ownedRestart!;
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log); _update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
_config = config; _config = config;
_identity = identity; _identity = identity;
_time = time; _time = time;
@@ -235,7 +235,7 @@ public sealed class AgentApp : IDisposable
{ {
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{ {
_update.WriteOffer(offer); _update?.Offer(offer);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}); });
} }
@@ -257,7 +257,7 @@ public sealed class AgentApp : IDisposable
{ {
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{ {
_update.RequestApply(); _update?.RequestApply();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}); });
} }
@@ -302,6 +302,7 @@ public sealed class AgentApp : IDisposable
{ {
var meta = new AgentHelloMeta var meta = new AgentHelloMeta
{ {
Owners = [.. _trust.Controllers],
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported, Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()], Addresses = [.. LocalAddresses.Sample()],
@@ -340,7 +341,7 @@ public sealed class AgentApp : IDisposable
DroppedInvalid = _counters.DroppedInvalid, DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited, DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes, QueueBytes = _outbox.PendingBytes,
Update = _update.ReadStatus(), Update = _update?.Status(),
}; };
_lastIn = packetsIn; _lastIn = packetsIn;
_lastOut = packetsOut; _lastOut = packetsOut;
+5 -10
View File
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MoShiro.Measurement;
namespace MoShiro.Agent; namespace MoShiro.Agent;
@@ -28,16 +29,6 @@ public sealed class AgentConfig
public string? RestartAt { get; set; } 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 public sealed class FleetSection
{ {
[JsonPropertyName("server")] [JsonPropertyName("server")]
@@ -45,6 +36,10 @@ public sealed class FleetSection
[JsonPropertyName("identity_dir")] [JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro"; 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 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 }; 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> /// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None); 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); config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt)); restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir); 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) catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{ {
+2 -2
View File
@@ -23,7 +23,7 @@
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。 2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。 3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。 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.*`)。 - JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。 - 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。 - 错误:`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 类型 | 正文`)上。 - 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 | | 保留方法 | 方向 | 说明 |
-12
View File
@@ -51,16 +51,4 @@ public static class VarInt
return false; 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; TrustBundle? bundle = null;
if (genesis is not 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); Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
if (!trusted.TryGet(out bundle, out FleetError? trustError)) if (!trusted.TryGet(out bundle, out FleetError? trustError))
{ {
+8 -1
View File
@@ -181,7 +181,7 @@ public static class ServiceShell
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排 // 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken; CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current; RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed); using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
try try
{ {
if (schedule is null) if (schedule is null)
@@ -196,6 +196,13 @@ public static class ServiceShell
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
if (restart.NowReason is { } reason)
{
log.Info("stopping", ("result", reason));
await cts.CancelAsync();
return;
}
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理 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 RestartSchedule? _local;
private readonly object _gate = new(); private readonly object _gate = new();
private RestartSchedule? _remote; private RestartSchedule? _remote;
private readonly CancellationTokenSource _now = new();
private CancellationTokenSource _changed = new(); private CancellationTokenSource _changed = new();
private string? _nowReason;
public RestartController(RestartSchedule? local = null) 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> /// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
public bool SetRemote(string? text) public bool SetRemote(string? text)
{ {
@@ -68,6 +96,7 @@ public sealed class RestartController : IDisposable
lock (_gate) lock (_gate)
{ {
_changed.Dispose(); _changed.Dispose();
_now.Dispose();
} }
} }
} }
@@ -25,7 +25,7 @@ public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary> /// <summary>
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。 /// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。 /// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。 /// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
/// </summary> /// </summary>
public sealed class TrustAnchor : IServerVerifier 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); public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary> /// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。 /// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本, /// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。 /// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。 /// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
@@ -7,7 +7,7 @@ namespace Fleet.Identity;
/// <summary> /// <summary>
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL: /// 验证一次 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 里。 /// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
/// </summary> /// </summary>
public static class WebAuthnAssertion 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 public sealed class ChallengeParams
{ {
[JsonPropertyName("nonce")] [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? genesis = Arg("--genesis");
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); 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) }; using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync( 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); http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
+676 -402
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -13,7 +13,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。 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 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 只写清单和请求文件)。 - Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;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。首次导入和自动更新见部署文档。
+45 -6
View File
@@ -22,17 +22,21 @@ public sealed class ProbeApp : IDisposable
private readonly SemaphoreSlim _changed = new(0); private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log; private readonly INodeLog _log;
private readonly FleetLink _link; private readonly FleetLink _link;
private readonly ReportingSection _reporting;
private readonly TimeProvider _time;
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param> /// <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) public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{ {
_log = log; _log = log;
_time = time;
_reporting = file.Reporting;
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log); var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust); 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); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release(); 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 var options = new FleetLinkOptions
{ {
Server = new Uri(file.Fleet.Server), Server = new Uri(file.Fleet.Server),
@@ -41,8 +45,8 @@ public sealed class ProbeApp : IDisposable
NodeId = identity.NodeId, NodeId = identity.NodeId,
NodeVersion = Version, NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base], 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), 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 { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta), 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>( var list = new StateHandler<ProbeAgentList>(
ProbeAgentList.StateType, ProbeAgentList.StateType,
@@ -55,12 +59,12 @@ public sealed class ProbeApp : IDisposable
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe) var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => .OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{ {
update.WriteOffer(offer); update?.Offer(offer);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}) })
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => .OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{ {
update.RequestApply(); update?.RequestApply();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}) })
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) => .OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
@@ -105,6 +109,7 @@ public sealed class ProbeApp : IDisposable
} }
Task link = _link.RunAsync(ct); Task link = _link.RunAsync(ct);
Task host = ReportHostAsync(ct);
try try
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
@@ -120,7 +125,41 @@ public sealed class ProbeApp : IDisposable
finally finally
{ {
await ReconcileAsync([], CancellationToken.None); 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 System.Text.Json.Serialization;
using MoShiro.Measurement;
using MoShiro.Protocol; using MoShiro.Protocol;
namespace MoShiro.Probe; namespace MoShiro.Probe;
@@ -18,6 +19,10 @@ public sealed class ProbeFile
[JsonPropertyName("limits")] [JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new(); 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 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> /// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")] [JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id"; 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 public sealed class KnownAgent
@@ -38,6 +38,12 @@ try
identity.Dispose(); identity.Dispose();
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token"); 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) 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"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" /> <InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
@@ -1,13 +1,13 @@
using System.Globalization; using System.Globalization;
using MoShiro.Protocol; using MoShiro.Protocol;
namespace MoShiro.Agent; namespace MoShiro.Measurement;
/// <summary> /// <summary>
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。 /// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。 /// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。 /// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
/// </summary> /// </summary>
public sealed class HostSampler public sealed class HostSampler
{ {
@@ -18,6 +18,7 @@ public sealed class HostSampler
private readonly Func<(long Total, long Used)?> _disk; private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot; private readonly string _procRoot;
private readonly string _sysRoot; private readonly string _sysRoot;
private readonly bool _native;
private (long Total, long Idle)? _previousCpu; private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet; 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) string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{ {
_time = time; _time = time;
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
_read = read ?? ReadProc; _read = read ?? ReadProc;
_procRoot = Root(procRoot, "HOST_PROC", "/proc"); _procRoot = Root(procRoot, "HOST_PROC", "/proc");
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys"); _sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
@@ -37,7 +39,8 @@ public sealed class HostSampler
long now = _time.GetTimestamp(); long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; 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) if (_previousCpu is { } before && cpu.Total > before.Total)
{ {
@@ -48,7 +51,8 @@ public sealed class HostSampler
_previousCpu = cpu; _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.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used; snapshot.MemUsed = memory.Used;
@@ -60,11 +64,21 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used; 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.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx; snapshot.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = ReadLinkSpeed(dev); snapshot.NetLinkMbps = net.LinkMbps;
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx) if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{ {
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds); double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -83,6 +97,12 @@ public sealed class HostSampler
return snapshot; 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> /// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
public static (long Total, long Idle)? ParseCpu(string stat) 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 CsrfFailed = new("csrf_failed");
public static readonly ErrorCode Forbidden = new("forbidden"); public static readonly ErrorCode Forbidden = new("forbidden");
public static readonly ErrorCode SitePrivate = new("site_private"); 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 NotFound = new("not_found");
public static readonly ErrorCode AgentNotFound = new("agent_not_found"); public static readonly ErrorCode AgentNotFound = new("agent_not_found");
public static readonly ErrorCode ProbeNotFound = new("probe_not_found"); public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
@@ -3,7 +3,6 @@ namespace MoShiro.Protocol;
public static class ProtocolVersion public static class ProtocolVersion
{ {
public const int Container = 1; public const int Container = 1;
public const int Wire = 1;
} }
public static class ProtocolLimits public static class ProtocolLimits
@@ -39,5 +38,4 @@ public static class MoShiroCapabilities
{ {
public const string Base = "moshiro.v1"; public const string Base = "moshiro.v1";
public const string KindsPrefix = "moshiro.kinds:"; 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.UpdateApply, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
// 节点面:Agent → Backend // 节点面:节点 → Backend
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent), MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Event, 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(监听方) // 对等面:Probe(拨号方)→ Agent(监听方)
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer), MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
@@ -306,6 +306,10 @@ public sealed class ProbeHeartbeatExtra
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary> /// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta public sealed class ProbeHelloMeta
{ {
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")] [JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; } public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -316,6 +320,10 @@ public sealed class ProbeHelloMeta
/// <summary><c>fleet.hello</c> 的附加字段。</summary> /// <summary><c>fleet.hello</c> 的附加字段。</summary>
public sealed class AgentHelloMeta public sealed class AgentHelloMeta
{ {
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")] [JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; } public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -5,6 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" /> <ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup> </ItemGroup>
<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,也可以只选其中几把 // 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted); OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis; 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)]]; Result<string[]> owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers);
if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c))) 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}"); http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
Result<CreatedJoinToken> result = await tokens.CreateAsync( 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); 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.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/join-tokens"; 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) 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) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found"); : 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); if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error); {
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) 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; namespace MoShiro.Backend.Api;
/// <summary>审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。</summary> /// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
public static class AuditEndpoints public static class AuditEndpoints
{ {
private const string Tag = "admin-audit"; private const string Tag = "admin-audit";
@@ -19,6 +19,14 @@ public static class AuthEndpoints
.AllowsAnonymous() .AllowsAnonymous()
.Api("auth_login", "Sign in with a username and password", Tag) .Api("auth_login", "Sign in with a username and password", Tag)
.Returns<LoginResponse>().ProducesProblems(400, 401, 429); .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) open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
.AllowsAnonymous() .AllowsAnonymous()
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag) .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) .Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404) .Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
.Requires(Capability.Authenticated); .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) guarded.MapGet("/auth/me", Me)
.Api("auth_me", "The current identity", Tag) .Api("auth_me", "The current identity", Tag)
.Returns<MeDto>().ProducesProblems(401) .Returns<MeDto>().ProducesProblems(401)
@@ -93,6 +85,32 @@ public static class AuthEndpoints
return IssueSession(http, login, request.ReturnSession == true, options); 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> /// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options) 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); 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) 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); Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
if (!result.TryGet(out CreationOptions? o, out FleetError? error)) 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) 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( 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); 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) 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) 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) return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
? TypedResults.NoContent() ? TypedResults.NoContent()
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey"); : 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); string? bearer = PrincipalResolver.BearerOf(http);
if (bearer is not null) if (bearer is not null)
{ {
@@ -36,21 +36,38 @@ public static class BuildEndpoints
.Requires(Capability.UpdateRead); .Requires(Capability.UpdateRead);
} }
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary> /// <summary>
public static void MapPublish(RouteGroupBuilder group) /// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
/// </summary>
public static void MapOffer(IEndpointRouteBuilder app)
{ {
group.MapPost("/releases", PublishReleaseAsync) app.MapPost("/api/release/offer", OfferReleaseAsync)
.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) .Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release")
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) .Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
.Requires(Capability.ReleasePublish);
} }
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)) var items = (await nodes.ListAsync(ct))
.OrderBy(n => n.Kind, StringComparer.Ordinal) .OrderBy(n => n.Kind, StringComparer.Ordinal)
.ThenBy(n => n.NodeId, 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(); .ToList();
http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto); 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) 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 ?? [])}"); http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); 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)) if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
@@ -8,7 +8,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api; namespace MoShiro.Backend.Api;
/// <summary> /// <summary>
/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它; /// 信任根(docs/security/access.md 第 8 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。 /// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
/// </summary> /// </summary>
public static class TrustEndpoints public static class TrustEndpoints
@@ -22,10 +22,9 @@ public static class TrustEndpoints
.Returns<TrustStateDto>().ProducesProblems(401, 403) .Returns<TrustStateDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead); .Requires(Capability.FleetRead);
guarded.MapPost("/trust", CommitAsync) 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) .Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage) .Requires(Capability.AccountsManage);
.ElevationExempt();
} }
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options) 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) private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
{ {
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, 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); Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
if (!result.TryGet(out TrustState? state, out FleetError? error)) if (!result.TryGet(out TrustState? state, out FleetError? error))
{ {
+4 -2
View File
@@ -18,6 +18,7 @@ public static class ApiModule
public static IServiceCollection AddMoShiroApi(this IServiceCollection services) public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
{ {
services.AddSingleton<SseLimiter>(); services.AddSingleton<SseLimiter>();
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
services.AddSingleton<ClientAddressResolver>(); services.AddSingleton<ClientAddressResolver>();
services.Configure<JsonOptions>(options => services.Configure<JsonOptions>(options =>
{ {
@@ -104,8 +105,6 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin"); RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin); AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync); RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded); AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded); AgentEndpoints.Map(guarded);
@@ -115,10 +114,13 @@ public static class ApiModule
TrustEndpoints.Map(guarded); TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded); SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded); BuildEndpoints.Map(guarded);
ReleaseKeyEndpoints.Map(guarded);
LinkEndpoints.Map(guarded); LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app); OpsEndpoints.Map(app);
BuildEndpoints.MapOffer(app);
app.MapOpenApi("/openapi/v1.json"); app.MapOpenApi("/openapi/v1.json");
app.MapStaticSite();
return app; return app;
} }
@@ -18,11 +18,6 @@ public static class EndpointConventions
return Task.CompletedTask; return Task.CompletedTask;
}); });
/// <summary>
/// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。
/// </summary>
public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker());
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary> /// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) => public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
builder.WithMetadata(new CapabilityRequirement(null)); builder.WithMetadata(new CapabilityRequirement(null));
@@ -60,9 +60,6 @@ public static class PrincipalResolver
/// </summary> /// </summary>
public sealed record CapabilityRequirement(string? Capability); 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> /// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
public static class CapabilityGuard 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)) if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
{ {
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里 // 写操作都记审计:响应发完之后才知道结果,所以放在 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> /// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
public static class PublicGuard public static class PublicGuard
{ {
@@ -156,9 +156,8 @@ public static class Mappers
public static JoinTokenDto ToDto(JoinTokenInfo t) => 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); 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); 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(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized); 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( Add(
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound, 404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound); 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); 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> /// <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); 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> /// <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 RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession); public sealed record 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); 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 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); 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); 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>
/// <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 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/&lt;角色&gt;.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="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param> /// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param> /// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param> /// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param> /// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</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> /// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); 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 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( public sealed record TaskDto(
string Id, 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); public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary> /// <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> /// <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); 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); 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 BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error);
public sealed record ConfirmBackendUpdateRequest(long Seq); public sealed record ConfirmBackendUpdateRequest(long Seq);
@@ -45,8 +45,11 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(ReleaseDto))] [JsonSerializable(typeof(ReleaseDto))]
[JsonSerializable(typeof(PageDto<ReleaseDto>))] [JsonSerializable(typeof(PageDto<ReleaseDto>))]
[JsonSerializable(typeof(PublishReleaseRequest))] [JsonSerializable(typeof(PublishReleaseRequest))]
[JsonSerializable(typeof(ReleaseKeysDto))]
[JsonSerializable(typeof(SetReleaseKeyRequest))]
[JsonSerializable(typeof(AboutDto))] [JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))] [JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeOwnerDto))]
[JsonSerializable(typeof(BuildInfoDto))] [JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(BackendUpdateDto))] [JsonSerializable(typeof(BackendUpdateDto))]
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))] [JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
@@ -81,10 +84,11 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(PasskeyLoginRequest))] [JsonSerializable(typeof(PasskeyLoginRequest))]
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")] [JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
[JsonSerializable(typeof(TrustStateDto))] [JsonSerializable(typeof(TrustStateDto))]
[JsonSerializable(typeof(ElevationRequest))] [JsonSerializable(typeof(SetupStatusDto))]
[JsonSerializable(typeof(ElevationAssertionRequest))] [JsonSerializable(typeof(SetupRequest))]
[JsonSerializable(typeof(ElevationDto))] [JsonSerializable(typeof(ComposeInstallDto))]
[JsonSerializable(typeof(PageDto<ElevationDto>))] [JsonSerializable(typeof(QuadletInstallDto))]
[JsonSerializable(typeof(ProposedCredentialDto))]
[JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PasskeyDto))]
[JsonSerializable(typeof(PageDto<PasskeyDto>))] [JsonSerializable(typeof(PageDto<PasskeyDto>))]
[JsonSerializable(typeof(CreatedApiTokenDto))] [JsonSerializable(typeof(CreatedApiTokenDto))]
@@ -177,9 +177,9 @@ public sealed record HostDto(
long? UptimeSeconds, long? UptimeSeconds,
DateTimeOffset At); DateTimeOffset At);
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary> /// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</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> /// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt); 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> /// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend); 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> /// <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> /// <summary>
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。 /// 一行安装脚本:<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> /// </summary>
public static class InstallScripts public static class InstallScripts
@@ -19,6 +19,18 @@ public static class InstallScripts
public static string Script(string resource) => Read(resource); 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> /// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource)))); 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.sh" LogicalName="install.sh" />
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" /> <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\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> </ItemGroup>
</Project> </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)) 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) .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); .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")) 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) .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"); .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) 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}"; string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted);
if (offer is null) if (offer is null)
{ {
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); 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; 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 public static class HostEndpoints
{ {
private const string Tag = "public"; private const string Tag = "public";
private const string AgentKind = "agent";
private const string ProbeKind = "probe";
public static void Map(RouteGroupBuilder group) public static void Map(RouteGroupBuilder group)
{ {
group.MapGet("/hosts", ListAsync) 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); .Returns<PageDto<HostItemDto>>().ProducesProblems(403);
group.MapGet("/agents/{agent_id}/host", HistoryAsync) group.MapGet("/hosts/{node_id}", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag) .Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag)
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422); .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>(); var items = new List<HostItemDto>();
foreach (AgentView view in await agents.ListAsync(ct)) 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); (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); 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); bool visible;
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))) 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) 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 now = time.GetUtcNow().ToUnixTimeMilliseconds();
long from = now - span; 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 个点:按分钟的整数倍取步长,每个步长内取平均 // 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000); 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), ["rx_bps"] = Average(s => s.NetRxBps),
["tx_bps"] = Average(s => s.NetTxBps), ["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( 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) BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
{ {
string ipVersion = ip_version ?? IpVersions.V4; string ipVersion = ip_version ?? IpVersions.V4;
@@ -84,7 +84,7 @@ public static class LiveEndpoints
http.Response.Headers["X-Accel-Buffering"] = "no"; http.Response.Headers["X-Accel-Buffering"] = "no";
await http.Response.Body.FlushAsync(http.RequestAborted); 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); using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
await session.RunAsync(linked.Token); await session.RunAsync(linked.Token);
} }
@@ -105,6 +105,8 @@ public static class LiveEndpoints
{ {
private readonly HttpContext _http; private readonly HttpContext _http;
private readonly AgentService _agents; private readonly AgentService _agents;
private readonly ProbeService _probes;
private readonly HostStatusService _hosts;
private readonly LineService _lines; private readonly LineService _lines;
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys; private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
private readonly string _ipVersion; private readonly string _ipVersion;
@@ -124,10 +126,12 @@ public static class LiveEndpoints
private long _lastWriteMs; private long _lastWriteMs;
private long _lastPeriodicMs; 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; _http = http;
_agents = agents; _agents = agents;
_probes = probes;
_hosts = hosts;
_lines = lines; _lines = lines;
_lineKeys = lineKeys; _lineKeys = lineKeys;
_ipVersion = ipVersion; _ipVersion = ipVersion;
@@ -171,7 +175,7 @@ public static class LiveEndpoints
_nodeSignals.Enqueue(node); _nodeSignals.Enqueue(node);
break; break;
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts): case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
_hostSignals[host.AgentId] = host; _hostSignals[host.Node] = host;
break; break;
case ChunkSignal chunk: case ChunkSignal chunk:
_dirtyLines.Add((chunk.AgentId, chunk.Series)); _dirtyLines.Add((chunk.AgentId, chunk.Series));
@@ -216,10 +220,20 @@ public static class LiveEndpoints
foreach (HostSignal host in hostSignals) 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) 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: 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)) 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; break;
@@ -61,7 +61,7 @@ public static class NodeEndpoints
var items = (await nodes.ListAsync(ct)) var items = (await nodes.ListAsync(ct))
.Where(n => visible.Contains(n.NodeId)) .Where(n => visible.Contains(n.NodeId))
.OrderBy(n => n.NodeId, StringComparer.Ordinal) .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(); .ToList();
http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto); 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)、 /// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。 /// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
/// </summary> /// </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> /// <summary>
public sealed record InstallTarget(string Role, string Server, string Code, string Image); /// Podman Quadlet 安装(Linux 上推荐):<c>Env</c> 存成 <c>~/.config/moshiro/&lt;角色&gt;.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> /// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
public sealed class JoinTokenService 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), 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); 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> /// <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)"); + $"$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 &amp;&amp; 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('/'); private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
/// <summary> /// <summary>
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。 /// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。 /// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。
/// </summary> /// </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) if (code.Length is 0 or > 200)
{ {
@@ -115,22 +208,11 @@ public sealed class JoinTokenService
} }
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct); 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 token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
{ ? null
return null; : RoleOf(token.Kind);
} }
string role = RoleOf(token.Kind);
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
string ns = _options.ImageNamespace.Trim('/');
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
string server = BaseUrl(requestBaseUrl);
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
}
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct); public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), 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 LinkService _links;
private readonly TrustService _trust; 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; _trust = trust;
_links = links; _links = links;
_nodes = nodes; _nodes = nodes;
@@ -147,6 +150,7 @@ public sealed partial class AgentService
await _gateway.DisconnectAsync(id, "deleted", ct); await _gateway.DisconnectAsync(id, "deleted", ct);
await _agents.DeleteAsync(id, ct); await _agents.DeleteAsync(id, ct);
await _nodes.ForgetAsync(id.Value, ct); await _nodes.ForgetAsync(id.Value, ct);
_owners.Forget(id.Value);
await _links.ForgetNodeAsync(id.Value, ct); await _links.ForgetNodeAsync(id.Value, ct);
_runtime.Remove(id); _runtime.Remove(id);
await AfterAgentSetChangedAsync(ct); await AfterAgentSetChangedAsync(ct);
@@ -4,8 +4,8 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application; namespace MoShiro.Backend.Application;
/// <summary> /// <summary>
/// <c>moshiro.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。 /// <c>moshiro.host</c>:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。 /// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。
/// </summary> /// </summary>
public sealed class HostStatusService public sealed class HostStatusService
{ {
@@ -69,9 +69,11 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly ReleaseService _releases; private readonly ReleaseService _releases;
private readonly ScheduleService _schedule; private readonly ScheduleService _schedule;
private readonly IProbeStore _probes; 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; _releases = releases;
_schedule = schedule; _schedule = schedule;
_probes = probes; _probes = probes;
@@ -148,6 +150,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
{ {
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta); ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct); await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
_owners.Report(id.Value, meta?.Owners);
if (meta?.Ipv6 is { } ipv6) if (meta?.Ipv6 is { } ipv6)
{ {
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct); 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); AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits); 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); 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 NodeInfoService _nodes;
private readonly LinkService _links; 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; _links = links;
_nodes = nodes; _nodes = nodes;
_probes = probes; _probes = probes;
@@ -94,6 +97,7 @@ public sealed partial class ProbeService
if (deleted) if (deleted)
{ {
await _nodes.ForgetAsync(id, ct); await _nodes.ForgetAsync(id, ct);
_owners.Forget(id);
await _links.ForgetNodeAsync(id, ct); await _links.ForgetNodeAsync(id, ct);
_gateway.NotifyAllTaskSetsChanged(); _gateway.NotifyAllTaskSetsChanged();
} }
@@ -38,10 +38,13 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<PasskeyService>(); services.AddSingleton<PasskeyService>();
services.AddSingleton<AuditService>(); services.AddSingleton<AuditService>();
services.AddSingleton<ServerIdentityService>(); 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<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
services.AddSingleton<TrustService>(); services.AddSingleton<TrustService>();
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>()); services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
services.AddSingleton<ElevationService>();
services.AddSingleton<SettingsService>(); services.AddSingleton<SettingsService>();
services.AddSingleton<ReleaseService>(); services.AddSingleton<ReleaseService>();
services.AddSingleton<ScheduleService>(); services.AddSingleton<ScheduleService>();
@@ -68,6 +71,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddHostedService(provider => provider.GetRequiredService<TestFinalizer>()); services.AddHostedService(provider => provider.GetRequiredService<TestFinalizer>());
services.AddHostedService<HotLayerBackfill>(); services.AddHostedService<HotLayerBackfill>();
services.AddHostedService<RetentionService>(); services.AddHostedService<RetentionService>();
services.AddHostedService<ReleasePoller>();
return services; return services;
} }
} }
@@ -12,10 +12,10 @@ public enum PrincipalKind
/// </summary> /// </summary>
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId) 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; } = ""; public string AuthMethod { get; init; } = "";
/// <summary>会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。</summary> /// <summary>会话的标识(令牌哈希的十六进制):API key 为空。</summary>
public string? SessionId { get; init; } public string? SessionId { get; init; }
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary> /// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
@@ -71,7 +71,7 @@ public sealed partial class AuthService
} }
UserRecord? user = await _store.GetUserAsync(session.UserId, ct); 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) public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
@@ -2,7 +2,7 @@ namespace MoShiro.Backend.Application;
/// <summary> /// <summary>
/// 能力(capability):一项具体的权限。端点声明自己需要哪一项,<see cref="Principal.Has"/> 是唯一的判断入口, /// 能力(capability):一项具体的权限。端点声明自己需要哪一项,<see cref="Principal.Has"/> 是唯一的判断入口,
/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。 /// 会话、API key 都走它。设计见 docs/security/access.md 第 3 节。
/// </summary> /// </summary>
public static class Capability public static class Capability
{ {
@@ -27,7 +27,6 @@ public static class Capability
public const string UpdateRead = "update.read"; public const string UpdateRead = "update.read";
public const string UpdateConfirm = "update.confirm"; public const string UpdateConfirm = "update.confirm";
public const string ReleasePublish = "release.publish";
public const string KeysRead = "keys.read"; public const string KeysRead = "keys.read";
public const string KeysManage = "keys.manage"; public const string KeysManage = "keys.manage";
@@ -39,13 +38,13 @@ public static class Capability
public const string EventsRead = "events.read"; public const string EventsRead = "events.read";
public const string OpsMetrics = "ops.metrics"; 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 DataExport = "data.export";
public const string WebhooksRead = "webhooks.read"; public const string WebhooksRead = "webhooks.read";
public const string WebhooksManage = "webhooks.manage"; 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 CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities); public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
@@ -74,7 +73,6 @@ public static class CapabilityCatalog
new(Capability.UpdateRead, "update"), new(Capability.UpdateRead, "update"),
new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead), new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead),
new(Capability.ReleasePublish, "update"),
new(Capability.KeysRead, "keys"), new(Capability.KeysRead, "keys"),
new(Capability.KeysManage, "keys", false, false, Capability.KeysRead), 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("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("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])),
new("enroll", Expand([Capability.FleetEnroll])), new("enroll", Expand([Capability.FleetEnroll])),
new("ci-release", [Capability.ReleasePublish]),
new("all", [.. All.Select(c => c.Id)]), new("all", [.. All.Select(c => c.Id)]),
]; ];
public static bool IsKnown(string id) => s_byId.ContainsKey(id); public static bool IsKnown(string id) => s_byId.ContainsKey(id);
public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id);
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary> /// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
public static IReadOnlyList<string> Expand(IEnumerable<string> ids) 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)]; 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> /// <summary>
/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期; /// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期;
/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 <see cref="WebAuthnAssertion"/>(节点以后也用)。 /// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 <see cref="WebAuthnAssertion"/>(节点以后也用)。
/// 设计见 docs/access.md 第 3 节。 /// 设计见 docs/security/access.md 第 4 节。
/// </summary> /// </summary>
public sealed class PasskeyService public sealed class PasskeyService
{ {
@@ -33,7 +33,6 @@ public sealed class PasskeyService
private readonly IAuthStore _store; private readonly IAuthStore _store;
private readonly AuthService _auth; private readonly AuthService _auth;
private readonly ElevationService _elevation;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly object _gate = new(); private readonly object _gate = new();
private readonly Dictionary<string, PendingChallenge> _challenges = []; private readonly Dictionary<string, PendingChallenge> _challenges = [];
@@ -43,13 +42,10 @@ public sealed class PasskeyService
private const string RegisterPurpose = "register"; private const string RegisterPurpose = "register";
private const string LoginPurpose = "login"; private const string LoginPurpose = "login";
private static string ElevatePurpose(ElevationClass elevation) => "elevate:" + Elevation.IdOf(elevation); public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time)
public PasskeyService(IAuthStore store, AuthService auth, ElevationService elevation, TimeProvider time)
{ {
_store = store; _store = store;
_auth = auth; _auth = auth;
_elevation = elevation;
_time = time; _time = time;
} }
@@ -118,37 +114,6 @@ public sealed class PasskeyService
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); : 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> /// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
private async ValueTask<PasskeyRecord?> VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct) 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; namespace MoShiro.Backend.Application;
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary> /// <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> /// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param> /// <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> /// <summary>
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。 /// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
/// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链, /// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链,
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。 /// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/security/access.md 第 8 节。
/// </summary> /// </summary>
public sealed class TrustService : IOperatorTrustSource, IDisposable public sealed class TrustService : IOperatorTrustSource, IDisposable
{ {
@@ -53,7 +56,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct); TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement); OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
bool inSync = current is not null && proposed is not null && Same(current, proposed); 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) 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))], [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
[_server.PublicKeySpki]); [_server.PublicKeySpki]);
string statement = OperatorTrust.Encode(next); 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) => private static bool Same(OperatorSet a, OperatorSet b) =>
@@ -82,7 +99,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
/// <summary> /// <summary>
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。 /// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
/// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。 /// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。
/// </summary> /// </summary>
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct) 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> /// <summary>
/// 解析并校验 WebAuthn 注册(<c>navigator.credentials.create</c> 的结果)。只接受 ES256;attestation 一律当 <c>none</c> 处理: /// 解析并校验 WebAuthn 注册(<c>navigator.credentials.create</c> 的结果)。只接受 ES256;attestation 一律当 <c>none</c> 处理:
/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。 /// 不验设备证明,信任浏览器的实现(docs/security/access.md)。CBOR 解析只在 Backend 里,节点不带。
/// </summary> /// </summary>
public static class WebAuthnRegistration public static class WebAuthnRegistration
{ {
@@ -21,6 +21,12 @@ public sealed class BackendOptions
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary> /// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
public string? BackendUpdateStatusDirectory { get; set; } public string? BackendUpdateStatusDirectory { get; set; }
/// <summary>
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
/// </summary>
public bool RequireOwner { get; set; } = true;
/// <summary>面板的对外地址(用于生成安装命令)。</summary> /// <summary>面板的对外地址(用于生成安装命令)。</summary>
public string? PublicUrl { get; set; } public string? PublicUrl { get; set; }
@@ -32,15 +38,6 @@ public sealed class BackendOptions
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary> /// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
public string? WebAuthnOrigins { get; set; } 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> /// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
public bool SecureCookies { get; set; } = true; public bool SecureCookies { get; set; } = true;
@@ -106,6 +103,12 @@ public sealed class BackendOptions
/// </summary> /// </summary>
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz"; public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
/// <summary>
/// 首次认领的认领码(可选):面板里还没有任何用户时,第一个管理员由部署的人自己在页面上填,需要这个码才能创建。
/// 不设置就在首次启动时随机生成并写进后端的日志。创建完第一个管理员后作废。
/// </summary>
public string? ClaimCode { get; set; }
/// <summary>初始管理员(只在没有任何用户时创建)。</summary> /// <summary>初始管理员(只在没有任何用户时创建)。</summary>
public string? InitialAdminUser { get; set; } public string? InitialAdminUser { get; set; }
@@ -28,8 +28,6 @@ public sealed class HotLayer
public void MarkReady() => _ready = true; public void MarkReady() => _ready = true;
public IEnumerable<(NodeId Agent, string Series)> Keys => _rings.Keys;
/// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary> /// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary>
public void Merge(NodeId agent, string series, RawChunk chunk) public void Merge(NodeId agent, string series, RawChunk chunk)
{ {
@@ -94,27 +92,6 @@ public sealed class HotLayer
return result; 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) private static void Prune(Ring ring, long cutoff)
{ {
while (ring.Buckets.Count > 0) 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; public sealed record TestSignal(TestId TestId) : LiveSignal;
/// <summary>某个 Agent 的主机资源占用有了新的快照(实时推给展示页)。</summary> /// <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 public sealed class LiveSubscription : IDisposable
{ {
@@ -45,8 +45,6 @@ public sealed class LiveHub
{ {
private readonly ConcurrentDictionary<LiveSubscription, byte> _subscriptions = new(); private readonly ConcurrentDictionary<LiveSubscription, byte> _subscriptions = new();
public int SubscriberCount => _subscriptions.Count;
public LiveSubscription Subscribe() public LiveSubscription Subscribe()
{ {
var subscription = new LiveSubscription(this); var subscription = new LiveSubscription(this);
@@ -66,6 +66,17 @@ public static class IspClassifier
return (Other, Shorten(org)); 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); private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase);
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary> /// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
@@ -16,7 +16,8 @@ public sealed record NodeInfo(
string? Version = null, string? Version = null,
string? Tag = null, string? Tag = null,
string? Commit = null, string? Commit = null,
string? BuiltAt = null); string? BuiltAt = null,
bool IspManual = false);
/// <summary> /// <summary>
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。 /// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
@@ -42,6 +42,31 @@ public sealed class NodeInfoService
return [.. _cache.Values.Select(m => m.Info)]; 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> /// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct) public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
{ {
@@ -101,8 +126,15 @@ public sealed class NodeInfoService
return; return;
} }
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
string country = geo.Country.ToUpperInvariant(); 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); await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
} }
finally finally
@@ -422,8 +422,4 @@ public sealed class QueryService
return gaps; 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; namespace MoShiro.Backend.Application;
/// <summary> /// <summary>
/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。 /// 启动时最先执行:截断没有索引记录的段文件尾部、(用配置里的凭据创建管理员,没有的话把首次认领码写进日志)创建默认的线路任务模板。
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。 /// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
/// </summary> /// </summary>
public sealed class StartupTasks : IHostedService public sealed class StartupTasks : IHostedService
@@ -12,9 +12,11 @@ public sealed class StartupTasks : IHostedService
private readonly AuthService _auth; private readonly AuthService _auth;
private readonly TaskService _tasks; private readonly TaskService _tasks;
private readonly ServerIdentityService _identity; 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; _identity = identity;
_raw = raw; _raw = raw;
_auth = auth; _auth = auth;
@@ -25,6 +27,7 @@ public sealed class StartupTasks : IHostedService
{ {
await _raw.RecoverAsync(cancellationToken); await _raw.RecoverAsync(cancellationToken);
await _auth.EnsureInitialAdminAsync(cancellationToken); await _auth.EnsureInitialAdminAsync(cancellationToken);
await _setup.AnnounceAsync(cancellationToken);
await _tasks.SeedDefaultsAsync(cancellationToken); await _tasks.SeedDefaultsAsync(cancellationToken);
await _identity.EnsureAsync(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>&lt;命名空间&gt;/moshiro-&lt;组件&gt;-manifest:&lt;通道&gt;</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> /// <summary>
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。 /// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
/// <b>面板不验签、也不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库; /// 登记入口(<c>/api/release/offer</c>)只收带有效 CI 签名的清单;面板<b>不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。 /// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
/// </summary> /// </summary>
public sealed partial class ReleaseService : IDisposable public sealed partial class ReleaseService : IDisposable
@@ -26,10 +26,15 @@ public sealed partial class ReleaseService : IDisposable
private readonly IAgentGateway _gateway; private readonly IAgentGateway _gateway;
private readonly IBackendUpdateFiles _backendFiles; private readonly IBackendUpdateFiles _backendFiles;
private readonly BackendOptions _options; 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(); 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; _settings = settings;
_gateway = gateway; _gateway = gateway;
_backendFiles = backendFiles; _backendFiles = backendFiles;
@@ -40,7 +45,7 @@ public sealed partial class ReleaseService : IDisposable
public void Dispose() => BackendGate.Dispose(); 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) public async ValueTask<Result<ReleaseOffer>> PublishAsync(string manifest, string signature, CancellationToken ct)
{ {
if (!TryParse(manifest, signature, out ReleaseOffer? offer, out string? problem)) 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); 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); await BackendGate.WaitAsync(ct);
try 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) public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
{ {
string? manifest = await _settings.GetAsync(Key(component, "manifest"), 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 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 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(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe : node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
: null; : 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> /// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
internal static class Schema internal static class Schema
{ {
public const int CurrentVersion = 13; public const int CurrentVersion = 16;
public static void Migrate(SqliteConnection connection) public static void Migrate(SqliteConnection connection)
{ {
@@ -221,6 +221,44 @@ internal static class Schema
connection.Exec("PRAGMA user_version = 13"); connection.Exec("PRAGMA user_version = 13");
transaction.Commit(); 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> /// <summary>
@@ -14,20 +14,20 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) => public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<NodeMeta>>( _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); ct);
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) => public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) =>
_db.ExecuteAsync( _db.ExecuteAsync(
c => c.Exec( 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) " + "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) ON CONFLICT(node_id) DO UPDATE SET " + "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, " + "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, " + "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), ("@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), ("@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); ct);
public ValueTask DeleteAsync(string nodeId, CancellationToken ct) => public ValueTask DeleteAsync(string nodeId, CancellationToken ct) =>
@@ -36,6 +36,6 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
private static NodeMeta Read(SqliteDataReader r) private static NodeMeta Read(SqliteDataReader r)
{ {
static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i); 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));
} }
} }
+2 -1
View File
@@ -19,7 +19,7 @@ public static class BackendHost
public const string ConfigurationSection = "MoShiro"; public const string ConfigurationSection = "MoShiro";
public const int MaxRequestBodyBytes = 64 * 1024; 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); WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
@@ -51,6 +51,7 @@ public static class BackendHost
services.AddMoShiroApplication(options); services.AddMoShiroApplication(options);
services.AddMoShiroGateway(); services.AddMoShiroGateway();
services.AddMoShiroApi(); services.AddMoShiroApi();
configureServices?.Invoke(services); // 测试替换发布公钥这类服务
WebApplication app = builder.Build(); WebApplication app = builder.Build();
// 静默死亡的 Agent(断电、断网)不会关闭连接:15 秒一个 Ping,20 秒没有 Pong 就关闭,这样面板上的在线状态才可信 // 静默死亡的 Agent(断电、断网)不会关闭连接:15 秒一个 Ping,20 秒没有 Pong 就关闭,这样面板上的在线状态才可信
@@ -3,7 +3,7 @@ using System.Xml.Linq;
namespace MoShiro.Backend.Tests; namespace MoShiro.Backend.Tests;
/// <summary> /// <summary>
/// 依赖规则(docs/project-layout.md 第 3 节):由测试在构建期强制。 /// 依赖规则(docs/dev/project-layout.md 第 3 节):由测试在构建期强制。
/// 读取仓库里所有 .csproj 和源文件,违反规则就失败。 /// 读取仓库里所有 .csproj 和源文件,违反规则就失败。
/// </summary> /// </summary>
public sealed class ArchitectureTests 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