47 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
justme a2f88a344b feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
2026-10-08 03:21:44 +08:00
justme 9bb6841dce feat: pinned agents and probes only accept peers carrying the operator's proof; the panel ships the signed operator chain with the rosters [CI SKIP] 2026-10-07 23:24:33 +08:00
justme fc2bbdcc6d feat: operator trust root: passkey-signed operator set, signed join codes, nodes verify and pin the chain at enrollment [CI SKIP] 2026-10-07 23:19:59 +08:00
justme 321cacac32 feat: operator trust chain library (passkey-signed operator set, grants and code-key bindings) [CI SKIP] 2026-10-07 23:09:09 +08:00
justme 36f9816f40 test: allow the installer manifest paths in the OpenAPI layout check [CI SKIP] 2026-10-07 23:02:50 +08:00
justme 310b2b5b19 feat: the one-line installer verifies the CI-signed release manifest and pulls the image by digest [CI] 2026-10-07 23:01:45 +08:00
justme a19e6548d3 feat: trust forwarded client addresses only from configured proxies (rate limit, audit, SSE) [CI]
Docs: access.md covers the missing node-side Backend identity and the hash-first install flow.
2026-10-07 22:51:44 +08:00
justme ff45ad086e feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI] 2026-10-07 22:29:24 +08:00
justme 1379b8c7cb feat: audit log of admin writes, sign-ins and key creation, with an audit tab [CI] 2026-10-07 22:17:52 +08:00
justme 359313a57b feat: record who created join codes and who added each node [CI] 2026-10-07 22:12:15 +08:00
justme 6385c1b9a3 feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI] 2026-10-07 21:57:19 +08:00
justme ddb6608e32 feat!: capabilities replace API token scopes; every admin endpoint declares one, with a picker UI [CI] 2026-10-07 20:56:36 +08:00
justme 55b80f0a97 docs: access control and enrollment trust chain design [CI SKIP] 2026-10-07 20:45:23 +08:00
justme af2962fc58 feat: one-line install for agents and probes from one shared script (curl | bash, zsh, PowerShell) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 20:02:38 +08:00
justme 5528753d45 feat: deploy-node.sh backend takes extra env settings with --env [CI SKIP] 2026-10-07 19:40:13 +08:00
justme b99b95c738 feat!: pre-handshake knock on the agent TCP ports; probes get the agent certificate public key [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 18:56:40 +08:00
justme f03988d68b feat: detect an agent's public IPv4 and IPv6 addresses automatically, manual values still win [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 18:12:17 +08:00
265 changed files with 16278 additions and 1615 deletions

No files matched your search

+4
View File
@@ -3,3 +3,7 @@ ci/index-backend-image.sh text eol=lf
ci/publish-release-offer.sh text eol=lf
ci/release-linux.sh text eol=lf
packaging/update/*.sh text eol=lf
packaging/container/*.sh text eol=lf
ci/release-node-images.sh text eol=lf
packaging/install/*.sh text eol=lf
packaging/install/*.ps1 text eol=lf
+5
View File
@@ -27,6 +27,9 @@ steps:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/release-node-images.sh
depends_on: []
@@ -37,6 +40,8 @@ steps:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/publish-gitea-release.sh
depends_on:
+15 -4
View File
@@ -15,6 +15,17 @@ when:
concurrency: 1
steps:
# 前端先打包好(几十秒),两个架构的 Backend 构建再并行:它们把 web/*/dist 内嵌进程序,只读这份产物,不再各自 npm ci
# (并行的两个 npm ci 会同时删 node_modules,互相搞坏)。步骤加了超时:卡住就几分钟内失败,不是耗着。
- name: web-build
image: bash
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
- npm ci --prefix web --prefer-offline
- timeout 600 npm run build --prefix web
depends_on: []
- name: linux-amd64-backend
image: bash
environment:
@@ -25,7 +36,8 @@ steps:
from_secret: zot_secret
commands:
- bash ci/release-linux.sh amd64 backend
depends_on: []
depends_on:
- web-build
- name: linux-arm64-backend
image: bash
@@ -37,7 +49,8 @@ steps:
from_secret: zot_secret
commands:
- bash ci/release-linux.sh arm64 backend
depends_on: []
depends_on:
- web-build
- name: backend-multiarch-index
image: bash
@@ -50,8 +63,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/index-backend-image.sh
depends_on:
-6
View File
@@ -27,8 +27,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh backend
depends_on: []
@@ -44,8 +42,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh agent
depends_on: []
@@ -61,8 +57,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh probe
depends_on: []
+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.
- A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests, `release_api_token` with the `release` scope). The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
## Project layout
@@ -38,6 +38,6 @@ Follow the user's requested validation scope. Do not start a full tag/release bu
## CI and releases
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/ops/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. Development image builds and releases must remain isolated in their respective Zot repository paths.
+40 -33
View File
@@ -12,45 +12,54 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
**它不做什么**
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
- 暂不包含告警(见文末待办)。
## 信任关系
```text
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
管理员 ── 密码或 passkey(Cookie 会话 + CSRF),或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
操作员 ── passkey 给「操作员集合」签名 ──▶ Backend 只转发 ──▶ 节点自己验链
(创世指纹和属主在安装时固定在节点上;集合里还登记了 Backend 的服务器密钥,节点据此认 Backend、加密链路)
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
Probe ── 先敲门,再用自己的密钥签名建立测量会话 ───────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ 镜像仓库 / Backend ──▶ 节点宿主机的更新器用本机公钥验签
```
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。
- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。
- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。
## 架构
```text
Web(展示端 · 管理端)
▲ REST + SSE
┌──────────────────────────────────────────────────────────┐
│ Backend(面板) │
│ Api · Registry · Planner · Links │
│ Releases · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────┘
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像)
▼ ▲
Web(展示端 · 管理端) │ 定时取清单
▲ REST + SSE │
┌──────────────────────────────────────────────────────────────┐
│ Backend(面板) │──────────────────────────────┘
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │
│ Releases(发布清单 · 可信发布密钥) │
│ Ingest · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ wss 长连接(节点发起) │ wss 长连接(节点发起)
│ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
```
两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
@@ -60,7 +69,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web
```
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/architecture.md`](docs/architecture.md)。**
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/design/architecture.md`](docs/design/architecture.md)。**
## 协议
@@ -69,7 +78,7 @@ Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
细节见 [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api.md`](docs/api.md)、[`docs/enrollment.md`](docs/enrollment.md)。
细节见 [`docs/design/protocol.md`](docs/design/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api/api.md`](docs/api/api.md)、[`docs/security/enrollment.md`](docs/security/enrollment.md)。
## 如何构建
@@ -80,7 +89,7 @@ dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet
npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist
```
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。第一次打开管理端会进认领向导,认领码在后端日志里(或用 `MoShiro__ClaimCode` 指定)。
测试和检查:
@@ -90,21 +99,19 @@ npm run lint:css --prefix web # 前端样式(颜色只能写
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
```
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/deployment.md`](docs/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/ops/deployment.md`](docs/ops/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
## 文档
| 文档 | 内容 |
| --- | --- |
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`docs/security.md`](docs/security.md) | 零信任设计稿(尚未实现):威胁模型、名册和操作员凭证、监听隐身、extension 授权 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
| [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 |
| [`docs/api.md`](docs/api.md)、[`docs/api-guidelines.md`](docs/api-guidelines.md) | API 规范与约束 |
| [`docs/deployment.md`](docs/deployment.md) | 构建、发布、CI、部署与更新 |
| [`docs/project-layout.md`](docs/project-layout.md)、[`docs/coding-style.md`](docs/coding-style.md) | 仓库结构与依赖规则、代码风格 |
| [`docs/README.md`](docs/README.md) | 文档索引,按类别组织 |
| [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 |
| [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 |
| [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 |
| [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 |
| [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 |
| [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) |
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
+4 -4
View File
@@ -12,12 +12,12 @@
## 任务与面板
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security.md`](docs/security.md) 第 9.2 节
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节
## 部署
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
[security.md](docs/security.md)
[security.md](docs/security/security.md)
## 访客页面
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
@@ -34,11 +34,11 @@
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/deployment.md`](docs/deployment.md)。
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/ops/deployment.md`](docs/ops/deployment.md)。
## 安全
- **零信任**:设计稿在 [`docs/security.md`](docs/security.md),按其中第 12 节的顺序实现(阶段零「协议方向声明表」已完成):Probe 自己生成密钥、Agent 预握手敲门、操作员凭证和名册、测量数据的签名和归属校验、入库配额和管理端 CSP、Backend 应用层身份、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT);可选的 UDP 单包授权、UDP 会话迁移的路径验证。
- **零信任**:设计和各缺口的状态在 [`docs/security/security.md`](docs/security/security.md)。已完成:协议方向声明表、Probe 自己生成密钥、Agent 预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定。还没做:测量数据的产生者签名和归属校验、入库配额、Probe 只拨公网地址、管理端 CSP 之外的输入校验、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT)、吊销表;可选的 UDP 单包授权、UDP 会话迁移的路径验证。
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
+1 -1
View File
@@ -13,7 +13,7 @@
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic>
<Version>0.1.1</Version>
<Version>0.4.0</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
+3 -3
View File
@@ -36,7 +36,7 @@ dotnet test --solution MoShiro.slnx
dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
```
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
@@ -49,6 +49,6 @@ cd backend
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
```
首次启动时可用 `MoShiro__InitialAdminUser` 和 `MoShiro__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api/api.md`](../docs/api/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
+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
```
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
## 注册与运行
@@ -47,7 +47,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
moshiro-agent --config /etc/moshiro/agent.json
```
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
@@ -58,6 +58,6 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
- 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
+24 -8
View File
@@ -1,3 +1,4 @@
using Fleet.Identity;
using System.Net;
using System.Text.Json;
using Fleet.Core;
@@ -16,6 +17,8 @@ public sealed class AgentApp : IDisposable
private readonly AgentConfig _config;
private readonly bool _ipv6Supported;
private readonly KnockGate _knock;
private readonly TrustAnchor _trust;
private readonly RestartController _restart;
private readonly RestartController? _ownedRestart;
private readonly LoadedNodeIdentity _identity;
@@ -28,7 +31,7 @@ public sealed class AgentApp : IDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20);
private readonly UpdateBridge _update;
private readonly RestartUpdater? _update;
private readonly BackendUplink _uplink;
private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp;
@@ -43,7 +46,7 @@ public sealed class AgentApp : IDisposable
{
_ownedRestart = restart is null ? new RestartController() : null;
_restart = restart ?? _ownedRestart!;
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
_config = config;
_identity = identity;
_time = time;
@@ -60,7 +63,10 @@ public sealed class AgentApp : IDisposable
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
_admission = new AgentAdmission(_streams);
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
var env = new BlockEnv
@@ -77,7 +83,7 @@ public sealed class AgentApp : IDisposable
};
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
@@ -92,6 +98,7 @@ public sealed class AgentApp : IDisposable
Listen = new IPEndPoint(bind, config.Listen.ControlPort),
Certificate = identity.Certificate,
LookupPeerKey = _tasks.LookupPeerKey,
Knock = _knock,
},
_hub.OnSessionAsync,
log,
@@ -118,6 +125,9 @@ public sealed class AgentApp : IDisposable
public int ControlPort => _peers.Port;
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
public int TrustVersion => _trust.Version;
public AgentCounters Counters => _counters;
public FleetLink Link => _link;
@@ -225,7 +235,7 @@ public sealed class AgentApp : IDisposable
{
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
_update.WriteOffer(offer);
_update?.Offer(offer);
return ValueTask.CompletedTask;
});
}
@@ -247,7 +257,7 @@ public sealed class AgentApp : IDisposable
{
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
_update.RequestApply();
_update?.RequestApply();
return ValueTask.CompletedTask;
});
}
@@ -265,6 +275,7 @@ public sealed class AgentApp : IDisposable
{
Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity,
ServerVerifier = _trust,
NodeId = _identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
@@ -284,14 +295,19 @@ public sealed class AgentApp : IDisposable
return link;
}
/// <summary>敲门关卡的名册:期望状态里的 peers(任务管理器在构造之后才有)。</summary>
private IReadOnlyList<byte[]> RosterKeys() => _tasks.PeerKeys();
private JsonElement? HelloMeta()
{
var meta = new AgentHelloMeta
{
Owners = [.. _trust.Controllers],
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()],
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsSpki = _identity.File.TlsSpki,
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
ProbeTcpPort = ControlPort,
ProbeUdpPorts = [.. UdpPorts],
LocalLimits = new LocalLimits
@@ -325,7 +341,7 @@ public sealed class AgentApp : IDisposable
DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes,
Update = _update.ReadStatus(),
Update = _update?.Status(),
};
_lastIn = packetsIn;
_lastOut = packetsOut;
+5 -10
View File
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
namespace MoShiro.Agent;
@@ -28,16 +29,6 @@ public sealed class AgentConfig
public string? RestartAt { get; set; }
}
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
}
public sealed class FleetSection
{
[JsonPropertyName("server")]
@@ -45,6 +36,10 @@ public sealed class FleetSection
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
}
public sealed class ListenSection
@@ -213,8 +213,6 @@ public sealed class PeerHub
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
@@ -28,6 +28,11 @@ try
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
@@ -1,4 +1,5 @@
using System.Text.Json;
using Fleet.Hosting;
using Fleet.Core;
using Fleet.Rpc;
using MoShiro.Measurement;
@@ -25,14 +26,16 @@ public sealed class TaskManager
private readonly TargetRunners _targets;
private readonly Func<IReadOnlyList<int>> _udpPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
private long _version;
private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts)
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_guardFactory = guardFactory;
_streams = streams;
_env = env;
@@ -52,6 +55,9 @@ public sealed class TaskManager
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(ProbeSession probe)
{
@@ -95,6 +101,8 @@ public sealed class TaskManager
_ = ct;
var result = new StateSyncResult { AppliedVersion = version };
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
_trust.Update(set.TrustChain);
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (PeerSpec peer in set.Peers)
{
+2 -2
View File
@@ -23,7 +23,7 @@
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/deployment.md`](../../docs/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
## 身份
@@ -56,7 +56,7 @@
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 8 节。
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 |
+29
View File
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace Fleet.Core;
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
public sealed class SignedStatementDto
{
[JsonPropertyName("statement")]
public string Statement { get; set; } = "";
[JsonPropertyName("credential_id")]
public string CredentialId { get; set; } = "";
[JsonPropertyName("authenticator_data")]
public string AuthenticatorData { get; set; } = "";
[JsonPropertyName("client_data_json")]
public string ClientDataJson { get; set; } = "";
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
}
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
public sealed class TrustBundle
{
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
}
-12
View File
@@ -51,16 +51,4 @@ public static class VarInt
return false;
}
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
{
if (source.Length < 4)
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
return true;
}
}
@@ -2,13 +2,17 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Enrollment.Client;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
[JsonSerializable(typeof(TrustBundle))]
internal sealed partial class TrustBundleContext : JsonSerializerContext;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false);
/// <summary>
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
public static class EnrollmentClient
{
public static async Task<Result<EnrollmentResult>> EnrollAsync(
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
string? genesis = null, IReadOnlyList<string>? controllers = null)
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
@@ -36,6 +41,24 @@ public static class EnrollmentClient
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
}
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
TrustBundle? bundle = null;
if (genesis is not null)
{
if (controllers is not { Count: > 0 })
{
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
}
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
if (!trusted.TryGet(out bundle, out FleetError? trustError))
{
return Result<EnrollmentResult>.Fail(trustError);
}
}
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
var body = new JsonObject
@@ -76,10 +99,46 @@ public static class EnrollmentClient
var store = new ConfigStore(identityDirectory);
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
if (bundle is not null)
{
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
}
// node.json 最后写:它存在就表示注册完整完成
store.Write(
LoadedNodeIdentity.NodeFileName,
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
}
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
{
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
if (!response.IsSuccessStatusCode)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
}
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
if (bundle is null || bundle.Chain.Count == 0)
{
return Refuse("the panel has no operator trust root set up");
}
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
}
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
{
return Refuse("an owner passkey in the install command is not in the operator set");
}
return Result<TrustBundle>.Ok(bundle);
}
}
+8 -1
View File
@@ -181,7 +181,7 @@ public static class ServiceShell
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
try
{
if (schedule is null)
@@ -196,6 +196,13 @@ public static class ServiceShell
}
catch (OperationCanceledException)
{
if (restart.NowReason is { } reason)
{
log.Info("stopping", ("result", reason));
await cts.CancelAsync();
return;
}
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
}
@@ -0,0 +1,48 @@
using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
/// <para>
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
/// </para>
/// </summary>
public static class OwnerGuard
{
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
public static string? Problem(TrustAnchor trust, bool require)
{
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
{
return null;
}
if (!trust.Pinned)
{
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
}
return trust.Controllers.Count == 0
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
: null;
}
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
public static bool Check(string identityDirectory, bool require, INodeLog log)
{
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
if (Problem(trust, require) is { } problem)
{
log.Error("startup failed", ("err", problem));
return false;
}
return true;
}
}
@@ -8,7 +8,9 @@ public sealed class RestartController : IDisposable
private readonly RestartSchedule? _local;
private readonly object _gate = new();
private RestartSchedule? _remote;
private readonly CancellationTokenSource _now = new();
private CancellationTokenSource _changed = new();
private string? _nowReason;
public RestartController(RestartSchedule? local = null)
{
@@ -41,6 +43,32 @@ public sealed class RestartController : IDisposable
}
}
/// <summary>应用要求现在就有序退出(比如管理员请求更新);退出的原因,没有就是 null。</summary>
public string? NowReason
{
get
{
lock (_gate)
{
return _nowReason;
}
}
}
/// <summary>应用要求现在就退出的令牌。</summary>
public CancellationToken NowToken => _now.Token;
/// <summary>要求进程现在就有序退出,由服务管理器拉起;不管有没有设置定时重启。</summary>
public void RequestNow(string reason)
{
lock (_gate)
{
_nowReason ??= reason;
}
_now.Cancel();
}
/// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
public bool SetRemote(string? text)
{
@@ -68,6 +96,7 @@ public sealed class RestartController : IDisposable
lock (_gate)
{
_changed.Dispose();
_now.Dispose();
}
}
}
@@ -0,0 +1,170 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
namespace Fleet.Hosting;
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
public sealed class TrustFile
{
[JsonPropertyName("genesis")]
public string Genesis { get; set; } = "";
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
[JsonPropertyName("controllers")]
public List<string> Controllers { get; set; } = [];
}
[JsonSerializable(typeof(TrustFile))]
public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary>
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
/// </summary>
public sealed class TrustAnchor : IServerVerifier
{
public const string FileName = "trust.json";
private readonly ConfigStore? _store;
private readonly INodeLog _log;
private readonly object _gate = new();
private readonly TrustFile? _file;
private OperatorSet? _current;
private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log)
{
_store = store;
_file = file;
_current = current;
_log = log;
}
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
public bool Pinned => _file is not null;
public string? Genesis => _file?.Genesis;
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
public int Version
{
get
{
lock (_gate)
{
return _current?.Version ?? 0;
}
}
}
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
public bool Required
{
get
{
lock (_gate)
{
return _current?.Servers is { Count: > 0 };
}
}
}
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
IReadOnlyList<byte[]> servers;
lock (_gate)
{
servers = _current?.Servers ?? [];
}
foreach (byte[] server in servers)
{
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
{
return true;
}
}
return false;
}
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
public IReadOnlyList<string> Controllers
{
get
{
lock (_gate)
{
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
}
}
}
public static TrustAnchor Load(string identityDirectory, INodeLog log)
{
string path = Path.Combine(identityDirectory, FileName);
if (!File.Exists(path))
{
return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log);
}
TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile);
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
// 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
}
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
}
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
public int Update(IReadOnlyList<SignedStatementDto>? chain)
{
lock (_gate)
{
if (_file is null || chain is not { Count: > 0 })
{
return _current?.Version ?? 0;
}
OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]);
if (candidate is null)
{
_log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it");
return _current!.Version;
}
if (candidate.Version > _current!.Version)
{
_file.Chain = [.. chain];
_current = candidate;
try
{
_store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save trust.json", ("err", ex.Message));
}
_log.Info("operator set updated", ("version", candidate.Version));
}
return _current.Version;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>
/// 预握手敲门:TCP 连接建立之后、TLS 握手之前,拨号方先发 32 字节 <c>nonce(16) | tag(16)</c>,
/// <c>tag = HMAC-SHA256(K_pair, 时间窗 | nonce)</c> 截断到 16 字节。监听方核对不过就什么都不写、直接断开,
/// 所以扫描器拿不到证书、TLS 参数和 JARM 指纹。<c>K_pair</c> 只有名册里的成员算得出:双方身份密钥做静态 ECDH,再经 HKDF 加用途标签派生。
/// TCP 的 SYN-ACK 由内核发出,端口在扫描器眼里仍是「开着但不说话」,不是「没监听」。
/// </summary>
public static class Knock
{
public const int PacketBytes = 32;
public const int NonceBytes = 16;
public const int TagBytes = 16;
/// <summary>时间窗长度;接受前后各一个窗,容忍时钟偏差。</summary>
public const int WindowSeconds = 30;
private const string Label = "moshiro-knock-v1";
/// <summary>两把身份密钥的配对密钥。info 里的两个公钥按字节序排列,所以两边算出的是同一个值。</summary>
public static byte[] DeriveKey(NodeIdentity own, ReadOnlySpan<byte> peerSpki)
{
byte[] secret = own.AgreeWith(peerSpki);
try
{
ReadOnlySpan<byte> mine = own.PublicKeySpki;
bool mineFirst = mine.SequenceCompareTo(peerSpki) <= 0;
byte[] info = [.. Encoding.UTF8.GetBytes(Label), .. (mineFirst ? mine : peerSpki), .. (mineFirst ? peerSpki : mine)];
return HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 32, null, info);
}
finally
{
CryptographicOperations.ZeroMemory(secret);
}
}
public static byte[] Build(ReadOnlySpan<byte> key, long nowMs)
{
byte[] packet = new byte[PacketBytes];
RandomNumberGenerator.Fill(packet.AsSpan(0, NonceBytes));
Tag(key, WindowOf(nowMs), packet.AsSpan(0, NonceBytes), packet.AsSpan(NonceBytes));
return packet;
}
/// <summary>tag 在前一个、当前、后一个时间窗里有一个对得上就通过。不管重放(重放由 <see cref="KnockGate"/> 按 nonce 去重)。</summary>
public static bool Check(ReadOnlySpan<byte> key, ReadOnlySpan<byte> packet, long nowMs)
{
if (packet.Length != PacketBytes)
{
return false;
}
long window = WindowOf(nowMs);
Span<byte> expected = stackalloc byte[TagBytes];
bool ok = false;
for (long w = window - 1; w <= window + 1; w++)
{
Tag(key, w, packet[..NonceBytes], expected);
ok |= CryptographicOperations.FixedTimeEquals(expected, packet[NonceBytes..]);
}
return ok;
}
private static long WindowOf(long nowMs) => nowMs / 1000 / WindowSeconds;
private static void Tag(ReadOnlySpan<byte> key, long window, ReadOnlySpan<byte> nonce, Span<byte> destination)
{
Span<byte> data = stackalloc byte[8 + NonceBytes];
BinaryPrimitives.WriteInt64BigEndian(data, window);
nonce.CopyTo(data[8..]);
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, data, full);
full[..TagBytes].CopyTo(destination);
}
}
@@ -0,0 +1,113 @@
using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
namespace Fleet.Identity;
/// <summary>
/// 监听方的敲门关卡:新连接的前 32 字节必须是名册里某个成员的敲门包,否则不写任何字节、直接断开。
/// 每个来源地址限速(陌生人用随机数据也要消耗 HMAC 计算),nonce 在时间窗内去重(防重放)。
/// 名册由调用方提供(对端公钥列表);配对密钥按公钥缓存,名册变了自然只算新的。
/// </summary>
public sealed class KnockGate
{
private static readonly TimeSpan s_readTimeout = TimeSpan.FromSeconds(3);
private readonly NodeIdentity _own;
private readonly Func<IReadOnlyList<byte[]>> _peers;
private readonly TimeProvider _time;
private readonly int _attemptsPerPeriod;
private readonly long _periodMs;
private readonly ConcurrentDictionary<string, byte[]> _keys = new();
private readonly object _gate = new();
private readonly Dictionary<string, long> _seenNonces = [];
private readonly Dictionary<string, (long WindowStartMs, int Count)> _sources = [];
private long _lastSweepMs;
public KnockGate(NodeIdentity own, Func<IReadOnlyList<byte[]>> peers, TimeProvider time, int attemptsPerPeriod = 300, TimeSpan? period = null)
{
_own = own;
_peers = peers;
_time = time;
_attemptsPerPeriod = attemptsPerPeriod;
_periodMs = (long)(period ?? TimeSpan.FromSeconds(10)).TotalMilliseconds;
}
/// <summary>读 32 字节并核对。通过返回 true,流从敲门包之后继续(TLS 或明文首帧);不通过返回 false,调用方直接关闭连接,不要写任何东西。</summary>
public async ValueTask<bool> AdmitAsync(Stream stream, IPAddress? source, CancellationToken ct)
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (!TryCountSource(source, now))
{
return false;
}
byte[] packet = new byte[Knock.PacketBytes];
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(s_readTimeout);
try
{
await stream.ReadExactlyAsync(packet, timeout.Token);
}
catch (Exception ex) when (ex is EndOfStreamException or OperationCanceledException or IOException or ObjectDisposedException)
{
return false;
}
return Accept(packet, now);
}
/// <summary>核对一个敲门包(不读流):名册里有成员的密钥对得上,并且 nonce 没用过。</summary>
public bool Accept(ReadOnlySpan<byte> packet, long nowMs)
{
bool matched = false;
foreach (byte[] peer in _peers())
{
byte[] key = _keys.GetOrAdd(Convert.ToBase64String(peer), _ => Knock.DeriveKey(_own, peer));
matched |= Knock.Check(key, packet, nowMs);
}
if (!matched)
{
return false;
}
string nonce = Convert.ToHexString(packet[..Knock.NonceBytes]);
lock (_gate)
{
Sweep(nowMs);
return _seenNonces.TryAdd(nonce, nowMs + (Knock.WindowSeconds * 3 * 1000L));
}
}
private bool TryCountSource(IPAddress? source, long nowMs)
{
string key = source is null ? "" : source.IsIPv4MappedToIPv6 ? source.MapToIPv4().ToString() : source.ToString();
lock (_gate)
{
Sweep(nowMs);
(long start, int count) = _sources.TryGetValue(key, out var entry) && nowMs - entry.WindowStartMs < _periodMs ? entry : (nowMs, 0);
_sources[key] = (start, count + 1);
return count < _attemptsPerPeriod;
}
}
private void Sweep(long nowMs)
{
if (nowMs - _lastSweepMs < 5000 && _seenNonces.Count < 4096 && _sources.Count < 4096)
{
return;
}
_lastSweepMs = nowMs;
foreach (string nonce in _seenNonces.Where(p => p.Value <= nowMs).Select(p => p.Key).ToList())
{
_seenNonces.Remove(nonce);
}
foreach (string source in _sources.Where(p => nowMs - p.Value.WindowStartMs >= _periodMs).Select(p => p.Key).ToList())
{
_sources.Remove(source);
}
}
}
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
public static class SignaturePurpose
{
public const string Auth = "auth";
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
public const string AuthSecure = "auth2";
public const string Peer = "peer";
public const string Enroll = "enroll";
}
@@ -41,6 +44,16 @@ public sealed class NodeIdentity : IDisposable
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
/// <summary>与对端公钥(SPKI)做静态 ECDH,返回原始共享秘密。只用于派生对称密钥(敲门),必须再经 KDF,不能直接当密钥用。</summary>
public byte[] AgreeWith(ReadOnlySpan<byte> peerSpki)
{
using ECDiffieHellman own = ECDiffieHellman.Create();
own.ImportPkcs8PrivateKey(_key.ExportPkcs8PrivateKey(), out _);
using ECDiffieHellman peer = ECDiffieHellman.Create();
peer.ImportSubjectPublicKeyInfo(peerSpki, out _);
return own.DeriveRawSecretAgreement(peer.PublicKey);
}
public byte[] Sign(string purpose, params string[] fields) =>
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
@@ -0,0 +1,171 @@
using System.Buffers.Text;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
/// <summary>
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
/// </summary>
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
/// </summary>
public static class OperatorTrust
{
public const string SetHeader = "moshiro-opset-v1";
public const string Genesis = "0";
public static string Encode(OperatorSet set)
{
var text = new StringBuilder();
text.Append(SetHeader).Append('\n')
.Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
.Append("prev=").Append(set.Previous).Append('\n')
.Append("rp=").Append(set.RpId).Append('\n')
.Append("origins=").Append(string.Join(',', set.Origins));
foreach (OperatorKey key in set.Keys)
{
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
}
foreach (byte[] server in set.Servers ?? [])
{
text.Append("\nserver=").Append(Convert.ToBase64String(server));
}
return text.ToString();
}
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
public static OperatorSet? ParseSet(string text)
{
string[] lines = text.Split('\n');
if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
|| !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
{
return null;
}
var keys = new List<OperatorKey>();
var servers = new List<byte[]>();
foreach (string line in lines.Skip(5))
{
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
{
keys.Add(new OperatorKey(value[..split], spki));
}
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
{
servers.Add(serverKey);
}
else
{
return null;
}
}
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
}
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
/// <summary>
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
/// 返回最后一版;不对返回 null。
/// </summary>
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> chain)
{
if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
{
return null;
}
OperatorSet? current = null;
string? previousFingerprint = null;
foreach (SignedStatement link in chain)
{
OperatorSet? next = ParseSet(link.Statement);
if (next is null)
{
return null;
}
if (current is null)
{
if (next.Version != 1 || !VerifyOperator(link, next))
{
return null;
}
}
else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
{
return null;
}
current = next;
previousFingerprint = Fingerprint(link.Statement);
}
return current;
}
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
{
OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
{
return false;
}
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
}
private static bool TryValue(string line, string key, out string value)
{
value = "";
if (!line.StartsWith(key + "=", StringComparison.Ordinal))
{
return false;
}
value = line[(key.Length + 1)..];
return true;
}
private static bool TryBase64(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return data.Length > 0;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
data = [];
return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
}
}
@@ -0,0 +1,173 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
/// </summary>
public interface IServerVerifier
{
bool Required { get; }
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
}
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
public static class ServerHello
{
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (CryptographicException)
{
return false;
}
}
}
/// <summary>
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
/// </summary>
public sealed class LinkCipher : IDisposable
{
public const int Overhead = 8 + 16;
private const byte NodeToServer = 0;
private const byte ServerToNode = 1;
private readonly AesGcm _send;
private readonly AesGcm _receive;
private readonly byte _sendDirection;
private readonly byte _receiveDirection;
private readonly object _gate = new();
private long _sendCounter;
private long _receiveCounter;
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
{
_send = new AesGcm(sendKey, 16);
_receive = new AesGcm(receiveKey, 16);
_sendDirection = sendDirection;
_receiveDirection = receiveDirection;
}
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
}
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
}
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
{
byte[] secret = own.AgreeRaw(remote);
try
{
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
return (material[..32], material[32..]);
}
finally
{
CryptographicOperations.ZeroMemory(secret);
}
}
/// <summary>加密一帧。</summary>
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
{
long counter;
lock (_gate)
{
counter = _sendCounter++;
}
byte[] output = new byte[Overhead + 1 + data.Length];
BinaryPrimitives.WriteInt64BigEndian(output, counter);
byte[] plaintext = new byte[1 + data.Length];
plaintext[0] = kind;
data.CopyTo(plaintext.AsSpan(1));
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
return output;
}
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
{
kind = 0;
data = [];
if (frame.Length < Overhead + 1)
{
return false;
}
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
lock (_gate)
{
if (counter != _receiveCounter)
{
return false;
}
int length = frame.Length - Overhead;
byte[] plaintext = new byte[length];
try
{
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
}
catch (CryptographicException)
{
return false;
}
_receiveCounter++;
kind = plaintext[0];
data = plaintext[1..];
return true;
}
}
private static byte[] Nonce(long counter)
{
byte[] nonce = new byte[12];
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
return nonce;
}
private static byte[] Aad(byte direction, long counter)
{
byte[] aad = new byte[9];
aad[0] = direction;
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
return aad;
}
public void Dispose()
{
_send.Dispose();
_receive.Dispose();
}
}
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
{
using ECDiffieHellman remote = ECDiffieHellman.Create();
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
return _key.DeriveRawSecretAgreement(remote.PublicKey);
}
/// <summary>
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
@@ -0,0 +1,11 @@
using Fleet.Core;
namespace Fleet.Identity;
public static class TrustMapping
{
public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature);
public static SignedStatementDto ToDto(this SignedStatement signed) =>
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
}
@@ -0,0 +1,85 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Fleet.Identity;
/// <summary>
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
/// </summary>
public static class WebAuthnAssertion
{
public const byte FlagUserPresent = 0x01;
public const byte FlagUserVerified = 0x04;
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
public static bool Verify(
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed, bool requireUserVerification, out uint signCount)
{
signCount = 0;
if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
{
return false;
}
byte flags = authenticatorData[32];
if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
{
return false;
}
if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
{
return false;
}
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
{
return false;
}
}
catch (CryptographicException)
{
return false;
}
signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
return true;
}
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed)
{
try
{
using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| root.GetProperty("type").GetString() != expectedType
|| root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
|| root.GetProperty("challenge").GetString() is not { } challenge)
{
return false;
}
byte[] decoded = Base64Url.DecodeFromChars(challenge);
return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
{
return false;
}
}
}
+72 -6
View File
@@ -66,6 +66,12 @@ public sealed class FleetLinkOptions
public long MaxInflightBytes { get; init; } = 4 << 20;
public Backoff Backoff { get; init; } = new();
/// <summary>
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
/// </summary>
public IServerVerifier? ServerVerifier { get; init; }
}
/// <summary>
@@ -77,6 +83,7 @@ public sealed class FleetLink
private const string RpcPath = "/api/agent/v1/rpc";
private readonly FleetLinkOptions _options;
private volatile bool _secure;
private readonly RpcRouter<LinkContext> _router;
private readonly Dictionary<string, IStateHandler> _handlers;
private readonly TimeProvider _time;
@@ -153,6 +160,9 @@ public sealed class FleetLink
}
}
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
public bool IsSecure => _secure && IsConnected;
private async Task<string> RunOnceAsync(CancellationToken ct)
{
using var socket = new ClientWebSocket();
@@ -160,7 +170,7 @@ public sealed class FleetLink
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
await socket.ConnectAsync(builder.Uri, ct);
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
string? failure = await AuthenticateAsync(transport, ct);
if (failure is not null)
@@ -168,6 +178,8 @@ public sealed class FleetLink
return failure;
}
_secure = transport.Secure;
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = session.RunAsync(sessionCts.Token);
@@ -211,7 +223,7 @@ public sealed class FleetLink
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
@@ -222,9 +234,44 @@ public sealed class FleetLink
}
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
EphemeralKey? ephemeral = null;
AuthParams authParams;
try
{
if (_options.ServerVerifier is { Required: true } verifier)
{
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
{
return "the server could not prove its identity (it is not the server your operator set authorizes)";
}
ephemeral = new EphemeralKey();
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
authParams = new AuthParams
{
NodeId = _options.NodeId.Value,
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
NodeEphemeral = nodeEphemeralText,
};
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
}
else
{
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
}
}
finally
{
ephemeral?.Dispose();
}
while (true)
{
@@ -259,7 +306,26 @@ public sealed class FleetLink
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
private static bool TryDecodeBase64(string? text, out byte[] data)
{
data = [];
if (string.IsNullOrEmpty(text))
{
return false;
}
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
return false;
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
{
RpcFrame? frame = await transport.ReceiveAsync(ct);
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
+16 -3
View File
@@ -22,6 +22,9 @@ public sealed class PeerListenerOptions
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
public required Func<string, byte[]?> LookupPeerKey { get; init; }
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
public required KnockGate Knock { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8;
@@ -98,7 +101,13 @@ public sealed class PeerListener
PeerSockets.EnableKeepAlive(client.Client);
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
Stream stream = client.GetStream();
if (!await _options.Knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, linked.Token))
{
return;
}
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
await tls.AuthenticateAsServerAsync(
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
linked.Token);
@@ -222,8 +231,10 @@ internal static class PeerSockets
public static class PeerConnector
{
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{
// 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生
byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey);
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
client.NoDelay = true;
PeerSockets.EnableKeepAlive(client.Client);
@@ -232,8 +243,10 @@ public static class PeerConnector
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
await client.ConnectAsync(host, port, linked.Token);
Stream stream = client.GetStream();
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token);
var tls = new SslStream(
client.GetStream(), leaveInnerStreamOpen: false,
stream, leaveInnerStreamOpen: false,
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
var transport = new TlsFrameTransport(tls, maxFrameBytes);
@@ -0,0 +1,57 @@
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
/// </summary>
internal sealed class SecureTransport : IRpcTransport
{
private readonly IRpcTransport _inner;
private LinkCipher? _cipher;
private bool _allowPlaintext = true;
public SecureTransport(IRpcTransport inner)
{
_inner = inner;
}
public bool Secure => _cipher is not null;
public void Enable(LinkCipher cipher) => _cipher = cipher;
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
RpcFrame? frame = await _inner.ReceiveAsync(ct);
if (frame is not { } received || _cipher is null)
{
return frame;
}
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
{
_allowPlaintext = false;
return new RpcFrame((FrameKind)kind, data);
}
if (_allowPlaintext && received.Kind == FrameKind.Text)
{
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
}
throw new IOException("a frame on the secure link failed to authenticate");
}
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
public async ValueTask DisposeAsync()
{
_cipher?.Dispose();
await _inner.DisposeAsync();
}
}
+12 -6
View File
@@ -40,12 +40,6 @@ public static class FleetMethodTable
]);
}
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
public static class FleetChannels
{
public const byte FirstProductChannel = 16;
}
public sealed class ChallengeParams
{
[JsonPropertyName("nonce")]
@@ -53,6 +47,14 @@ public sealed class ChallengeParams
[JsonPropertyName("expires_at_ms")]
public long ExpiresAtMs { get; set; }
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
[JsonPropertyName("server_eph")]
public string? ServerEphemeral { get; set; }
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
[JsonPropertyName("server_sig")]
public string? ServerSignature { get; set; }
}
public sealed class AuthParams
@@ -63,6 +65,10 @@ public sealed class AuthParams
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
[JsonPropertyName("node_eph")]
public string? NodeEphemeral { get; set; }
}
public sealed class AuthResult
@@ -33,7 +33,8 @@ public sealed record NewNode(
string? NodeVersion,
long NowMs,
string Kind = "",
byte[]? TlsPublicKey = null);
byte[]? TlsPublicKey = null,
string? AddedBy = null);
public sealed record JoinTokenRecord(
string Id,
@@ -46,6 +47,7 @@ public sealed record JoinTokenRecord(
long ExpiresAtMs,
long? RevokedAtMs,
long CreatedAtMs,
string Kind = "");
string Kind = "",
string? CreatedBy = null);
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
@@ -6,6 +6,24 @@ namespace Fleet.Server;
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
/// <summary>
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
/// </summary>
public interface IServerIdentity
{
byte[] PublicKeySpki { get; }
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
byte[] Sign(ReadOnlySpan<byte> payload);
}
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
public interface IOperatorTrustSource
{
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
}
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
public interface IEnrollmentHook
{
@@ -37,6 +37,12 @@ public sealed class EnrollRequest
public JsonElement? Meta { get; set; }
}
public sealed class TrustRequest
{
[JsonPropertyName("join_token")]
public string JoinToken { get; set; } = "";
}
public sealed class EnrollResponse
{
[JsonPropertyName("node_id")]
@@ -69,5 +75,7 @@ public sealed class FleetProblem
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(EnrollRequest))]
[JsonSerializable(typeof(EnrollResponse))]
[JsonSerializable(typeof(TrustRequest))]
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
[JsonSerializable(typeof(FleetProblem))]
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
@@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints
.WithTags("agent")
.WithSummary("Enroll a node with a join token")
.DisableAntiforgery();
// 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去
app.MapPost(path + "/trust", HandleTrustAsync)
.WithName("fleet_enroll_trust")
.WithTags("agent")
.WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls")
.DisableAntiforgery();
return app;
}
private static async Task<IResult> HandleTrustAsync(HttpContext http, FleetEnrollmentService service)
{
TrustRequest? request;
try
{
request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted);
}
catch (JsonException)
{
request = null;
}
if (request is null || string.IsNullOrEmpty(request.JoinToken))
{
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required");
}
Result<TrustBundle> result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted);
if (!result.TryGet(out TrustBundle? bundle, out FleetError? error))
{
return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message);
}
http.Response.Headers.CacheControl = "no-store";
return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle);
}
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
{
EnrollRequest? request;
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
{
private readonly IFleetStore _store;
private readonly IEnumerable<IEnrollmentHook> _hooks;
private readonly IEnumerable<IOperatorTrustSource> _trust;
private readonly FleetServerOptions _options;
private readonly TimeProvider _time;
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time)
{
_store = store;
_hooks = hooks;
_trust = trust;
_options = options;
_time = time;
}
@@ -80,7 +82,8 @@ public sealed partial class FleetEnrollmentService
request.NodeVersion,
nowMs,
token.Kind,
tlsPublicKey);
tlsPublicKey,
token.CreatedBy);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
@@ -101,6 +104,30 @@ public sealed partial class FleetEnrollmentService
});
}
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
{
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
}
if (token.ExpiresAtMs <= nowMs)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
}
var bundle = new TrustBundle();
foreach (IOperatorTrustSource source in _trust)
{
bundle.Chain.AddRange(await source.ChainAsync(ct));
}
return Result<TrustBundle>.Ok(bundle);
}
private static bool TryDecode(string text, out byte[] data)
{
try
@@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<IStateProvider> stateProviders,
IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks,
FleetRouter router)
FleetRouter router,
IServerIdentity? serverIdentity = null)
{
ServerIdentity = serverIdentity;
Store = store;
Options = options;
Time = time;
@@ -38,6 +40,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
internal IFleetStore Store { get; }
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; }
internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; }
@@ -109,7 +109,8 @@ public static class FleetServerExtensions
provider.GetServices<IStateProvider>(),
provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>()));
provider.GetRequiredService<FleetRouter>(),
provider.GetService<IServerIdentity>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder;
@@ -14,9 +14,19 @@ internal sealed partial class NodeConnection
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
await SendAsync(
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
ct);
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
string? serverEphemeralText = null;
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
{
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
challenge.ServerEphemeral = serverEphemeralText;
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
}
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
@@ -48,10 +58,21 @@ internal sealed partial class NodeConnection
}
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
byte[] nodeEphemeral = [];
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
bool signatureOk = node is not null
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
&& TryDecode(auth!.Signature, out byte[] signature)
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
&& (secure
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
if (signatureOk && secure)
{
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
}
if (!signatureOk)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
@@ -75,7 +75,15 @@ internal sealed partial class NodeConnection
return false;
}
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
if (_cipher is { } cipher)
{
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
}
else
{
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
}
return true;
}
catch (WebSocketException)
@@ -30,6 +30,19 @@ internal sealed partial class NodeConnection
if (result.EndOfMessage)
{
if (_cipher is { } cipher)
{
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
{
_closeReason ??= "frame failed authentication";
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
return null;
}
return new Frame(kind == 0, plain);
}
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
}
}
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Threading.Channels;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Server;
@@ -24,6 +25,7 @@ internal sealed partial class NodeConnection : IDisposable
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _lifetime = new();
private LinkCipher? _cipher;
private long _nextRequestId;
private long _lastPresenceTicks;
private NodeRecord? _node;
@@ -134,6 +136,7 @@ internal sealed partial class NodeConnection : IDisposable
public void Dispose()
{
_cipher?.Dispose();
_lifetime.Dispose();
_sendLock.Dispose();
}
@@ -0,0 +1,112 @@
using System.Net;
using Fleet.Identity;
namespace Fleet.Tests;
public sealed class KnockTests
{
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => Now;
}
[Fact]
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
{
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
Assert.Equal(fromProbe, fromAgent);
Assert.Equal(32, fromProbe.Length);
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
}
[Fact]
public void Packet_IsAcceptedWithinTheWindowAndOneWindowOfSkew_ButNotBeyond()
{
byte[] key = new byte[32];
long now = new DateTimeOffset(2026, 10, 7, 12, 0, 15, TimeSpan.Zero).ToUnixTimeMilliseconds();
byte[] packet = Knock.Build(key, now);
Assert.Equal(Knock.PacketBytes, packet.Length);
Assert.True(Knock.Check(key, packet, now));
Assert.True(Knock.Check(key, packet, now + (Knock.WindowSeconds * 1000)));
Assert.True(Knock.Check(key, packet, now - (Knock.WindowSeconds * 1000)));
Assert.False(Knock.Check(key, packet, now + (3 * Knock.WindowSeconds * 1000)));
Assert.False(Knock.Check(key, packet, now - (3 * Knock.WindowSeconds * 1000)));
}
[Fact]
public void Packet_WithAWrongKeyATamperedByteOrTheWrongLength_IsRejected()
{
byte[] key = new byte[32];
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(key, now);
Assert.False(Knock.Check(new byte[32] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, packet, now));
foreach (int index in new[] { 0, 15, 16, 31 })
{
byte[] tampered = (byte[])packet.Clone();
tampered[index] ^= 0x01;
Assert.False(Knock.Check(key, tampered, now), $"byte {index}");
}
Assert.False(Knock.Check(key, packet.AsSpan(0, 31), now));
Assert.False(Knock.Check(key, [], now));
}
[Fact]
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity stranger = NodeIdentity.Generate();
var roster = new List<byte[]> { probe.PublicKeySpki };
var gate = new KnockGate(agent, () => roster, new Clock());
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
Assert.True(gate.Accept(packet, now));
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
// 名册变了:新成员马上能进,被移除的马上进不来
using NodeIdentity second = NodeIdentity.Generate();
roster.Add(second.PublicKeySpki);
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
roster.RemoveAt(0);
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
}
[Fact]
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
var clock = new Clock();
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
IPAddress noisy = IPAddress.Parse("203.0.113.9");
IPAddress quiet = IPAddress.Parse("203.0.113.10");
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
for (int i = 0; i < 3; i++)
{
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[Knock.PacketBytes]), noisy, default));
}
// 超过限额:连有效的敲门包也不读、不算
byte[] valid = Knock.Build(key, clock.Now.ToUnixTimeMilliseconds());
Assert.False(await gate.AdmitAsync(new MemoryStream(valid), noisy, default));
Assert.True(await gate.AdmitAsync(new MemoryStream(valid), quiet, default));
// 过了统计周期,恢复
clock.Now += TimeSpan.FromSeconds(11);
Assert.True(await gate.AdmitAsync(new MemoryStream(Knock.Build(key, clock.Now.ToUnixTimeMilliseconds())), noisy, default));
// 流在 32 字节之前就结束:拒绝,不抛异常
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[10]), quiet, default));
}
}
+69 -10
View File
@@ -280,12 +280,20 @@ public sealed class LinkTests
private sealed class PeerFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _stop = new();
private readonly NodeIdentity _identity;
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
{
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
Listener = new PeerListener(
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
new PeerListenerOptions
{
Listen = new IPEndPoint(IPAddress.Loopback, 0),
Certificate = Certificate,
LookupPeerKey = lookup,
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
},
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System);
Task = Listener.RunAsync(_stop.Token);
@@ -299,10 +307,17 @@ public sealed class LinkTests
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
public List<byte[]> KnockPeers { get; } = [];
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
await Task;
_identity.Dispose();
Certificate.Dispose();
_stop.Dispose();
}
@@ -325,9 +340,10 @@ public sealed class LinkTests
seen.TrySetResult(info);
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
});
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using (transport)
{
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -343,17 +359,18 @@ public sealed class LinkTests
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity impostor = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
await agent.Listener.Started.Task;
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
Assert.Equal(unknown.Message, forged.Message);
Assert.Contains("fleet_unauthorized", forged.Message);
// 正主仍然可以连接
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
@@ -362,11 +379,13 @@ public sealed class LinkTests
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
byte[] wrong = SHA256.HashData("another certificate"u8);
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
using NodeIdentity another = NodeIdentity.Generate();
await Assert.ThrowsAnyAsync<Exception>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
}
[Fact]
@@ -382,9 +401,10 @@ public sealed class LinkTests
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
await session.RunAsync(ct);
});
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
@@ -398,6 +418,7 @@ public sealed class LinkTests
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 不说 TLS 的连接:握手失败,监听继续
@@ -407,7 +428,45 @@ public sealed class LinkTests
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
}
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
foreach (byte[] payload in new[] { new byte[] { 0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00 }.Concat(RandomNumberGenerator.GetBytes(64)).ToArray(), RandomNumberGenerator.GetBytes(32), [] })
{
using var scanner = new TcpClient();
await scanner.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
NetworkStream stream = scanner.GetStream();
if (payload.Length > 0)
{
await stream.WriteAsync(payload);
}
byte[] buffer = new byte[64];
int read;
try
{
read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
}
catch (IOException)
{
read = 0; // 连接被重置也是「什么都没收到」
}
Assert.Equal(0, read);
}
// 名册里的 Probe 仍然进得来
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
@@ -0,0 +1,123 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
internal sealed class TestAuthenticator : IDisposable
{
public const string RpId = "panel.example.com";
public const string Origin = "https://panel.example.com";
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private uint _counter;
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
{
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
_counter++;
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
}
public void Dispose() => _key.Dispose();
}
public sealed class OperatorTrustTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly TestAuthenticator _bob = new();
public void Dispose()
{
_alice.Dispose();
_bob.Dispose();
}
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
[Fact]
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
{
OperatorSet set = Genesis(_alice);
SignedStatement genesis = SignedGenesis(set, _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
Assert.Null(OperatorTrust.VerifyChain(pin, []));
}
[Fact]
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
{
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(real.Statement);
using var attacker = new TestAuthenticator();
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
}
[Fact]
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
{
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
Assert.Equal(2, current?.Version);
Assert.Equal(2, current?.Keys.Count);
// 新加的 bob 可以签下一版(上一版里有他)
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
// 版本号跳了、prev 不对、域名换了:都不行
var skipped = second with { Version = 3 };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
var wrongPrev = second with { Previous = new string('a', 64) };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
var otherRp = second with { RpId = "evil.example" };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
}
[Fact]
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
{
OperatorSet set = Genesis(_alice);
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
}
[Fact]
public void Parsing_IsStrict()
{
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
OperatorSet set = Genesis(_alice);
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
}
}
@@ -0,0 +1,70 @@
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
public sealed class OwnerGuardTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
public OwnerGuardTests()
{
Directory.CreateDirectory(_directory);
}
public void Dispose()
{
_alice.Dispose();
Directory.Delete(_directory, recursive: true);
}
private void Pin(params string[] controllers)
{
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
}
[Fact]
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.NotNull(OwnerGuard.Problem(none, require: true));
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
}
[Fact]
public void ANodePinnedToAnOwnerPasskey_Runs()
{
Pin(_alice.CredentialId);
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(pinned, require: true));
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
Assert.Equal([_alice.CredentialId], pinned.Controllers);
}
[Fact]
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
{
Pin("some-passkey-that-is-not-in-the-set");
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Empty(pinned.Controllers);
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
}
[Fact]
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(none, require: false));
}
}
@@ -0,0 +1,114 @@
using System.Security.Cryptography;
using Fleet.Identity;
namespace Fleet.Tests;
public sealed class SecureChannelTests
{
private const string Nonce = "n0nce-from-the-server";
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
{
using var node = new EphemeralKey();
using var server = new EphemeralKey();
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
}
[Fact]
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
{
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var eph = new EphemeralKey();
using var otherEph = new EphemeralKey();
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
}
[Fact]
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] toServer = n.Seal(0, "hello server"u8);
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
Assert.Equal(0, kind);
Assert.Equal("hello server"u8.ToArray(), data);
byte[] big = RandomNumberGenerator.GetBytes(300_000);
byte[] toNode = s.Seal(1, big);
Assert.True(n.TryOpen(toNode, out kind, out data));
Assert.Equal(1, kind);
Assert.Equal(big, data);
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
}
[Fact]
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] first = n.Seal(0, "one"u8);
byte[] second = n.Seal(0, "two"u8);
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
Assert.True(s.TryOpen(first, out _, out _));
Assert.False(s.TryOpen(first, out _, out _)); // 重放
Assert.True(s.TryOpen(second, out _, out _));
byte[] tampered = n.Seal(0, "three"u8);
tampered[12] ^= 0x01;
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
byte[] fromNode = n.Seal(0, "four"u8);
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
using LinkCipher on = otherNode;
using LinkCipher os = otherServer;
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
byte[] fromServer = s.Seal(0, "reflected"u8);
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
}
[Fact]
public void ADifferentNonce_GivesDifferentKeys()
{
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
using LinkCipher n = node;
using LinkCipher s = server;
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
}
[Fact]
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
{
using var alice = new TestAuthenticator();
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
string text = OperatorTrust.Encode(set);
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
Assert.Equal(spki, Assert.Single(parsed.Servers!));
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
SignedStatement genesis = alice.Sign(text);
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
Assert.Equal(spki, Assert.Single(verified.Servers!));
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
}
}
+12 -3
View File
@@ -12,7 +12,7 @@ string? name = Arg("--name");
string? directory = Arg("--identity-dir");
if (server is null || join is null || name is null || directory is null)
{
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]");
return 2;
}
@@ -30,14 +30,23 @@ if (Arg("--meta") is { } metaText)
}
}
string? genesis = Arg("--genesis");
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (genesis is not null && controllers.Length == 0)
{
Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)");
return 2;
}
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
{
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
return 1;
}
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)"));
return 0;
+1848 -94
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`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
## 构建
@@ -30,7 +30,7 @@ dotnet build probe/src/MoShiro.Probe -c Release
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
```
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
## 配置与运行
@@ -54,6 +54,6 @@ moshiro-probe --config /etc/moshiro/probe.json
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
- 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。
Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -89,8 +89,9 @@ public sealed class AgentConnection
throw new SocketException((int)SocketError.HostNotFound);
}
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var remote = (IPEndPoint)info.Remote!;
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
@@ -105,7 +106,8 @@ public sealed class AgentConnection
Session = () => data,
Udp = udp,
PeerAddress = remote.Address,
PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki),
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
Fragments = shipper,
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
MaxBulkMbps = _env.Limits.MaxTestMbps,
@@ -16,11 +16,13 @@ public sealed class AgentDirectory
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private readonly TrustAnchor _trust;
private List<KnownAgent> _agents = [];
private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log)
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
@@ -68,16 +70,17 @@ public sealed class AgentDirectory
public void Apply(ProbeAgentList result)
{
var next = new List<KnownAgent>();
_trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
if (!string.IsNullOrWhiteSpace(agent.Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, Spki = agent.Spki });
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
}
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, Spki = agent.Spki, IpVersion = IpVersions.V6 });
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate)
@@ -108,5 +111,5 @@ public sealed class AgentDirectory
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
}
+48 -7
View File
@@ -22,25 +22,31 @@ public sealed class ProbeApp : IDisposable
private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log;
private readonly FleetLink _link;
private readonly ReportingSection _reporting;
private readonly TimeProvider _time;
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
_time = time;
_reporting = file.Reporting;
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log);
var options = new FleetLinkOptions
{
Server = new Uri(file.Fleet.Server),
Identity = identity.Identity,
ServerVerifier = trust,
NodeId = identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base],
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update?.Status(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
};
var list = new StateHandler<ProbeAgentList>(
ProbeAgentList.StateType,
@@ -53,12 +59,12 @@ public sealed class ProbeApp : IDisposable
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
update.WriteOffer(offer);
update?.Offer(offer);
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
update.RequestApply();
update?.RequestApply();
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
@@ -103,6 +109,7 @@ public sealed class ProbeApp : IDisposable
}
Task link = _link.RunAsync(ct);
Task host = ReportHostAsync(ct);
try
{
while (!ct.IsCancellationRequested)
@@ -118,7 +125,41 @@ public sealed class ProbeApp : IDisposable
finally
{
await ReconcileAsync([], CancellationToken.None);
await Task.WhenAny(link, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
}
}
/// <summary>每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。</summary>
private async Task ReportHostAsync(CancellationToken ct)
{
if (!_reporting.HostStats)
{
return;
}
var sampler = new HostSampler(_time);
sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_reporting.HostIntervalSeconds, 1, 60)), _time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
if (_link.CurrentSession is { } session)
{
try
{
await session.NotifyAsync(MoShiroMethods.Host, sampler.Sample(), MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_log.Warn($"could not report host status: {ex.Message}");
}
}
}
}
catch (OperationCanceledException)
{
// 停止
}
}
+12 -3
View File
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
@@ -18,6 +19,10 @@ public sealed class ProbeFile
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
/// <summary>上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 <c>host_stats: false</c> 就一个字也不上报。</summary>
[JsonPropertyName("reporting")]
public ReportingSection Reporting { get; set; } = new();
}
public sealed class ProbeFleetSection
@@ -29,6 +34,10 @@ public sealed class ProbeFleetSection
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
}
public sealed class KnownAgent
@@ -48,9 +57,9 @@ public sealed class KnownAgent
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
/// <summary>Agent 的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("spki")]
public string Spki { get; set; } = "";
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
public sealed class ProbeLimits
@@ -38,6 +38,12 @@ try
identity.Dispose();
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
}
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
@@ -58,7 +58,7 @@ public sealed class ProbeTests
private static ProbeAgentList List(long version, params string[] ids) => new()
{
Version = version,
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, Spki = "c3BraQ==" })],
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })],
};
[Fact]
@@ -217,6 +217,9 @@ public sealed class BlockEnv
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
public byte[]? PeerSpkiSha256 { get; init; }
/// <summary>拨号方:和对端(Agent)的配对敲门密钥;每条 TCP 连接建立后先发敲门包。null 表示对端不要求敲门(目标任务的任意地址)。</summary>
public byte[]? KnockKey { get; init; }
/// <summary>拨号方:列块出口。</summary>
public IFragmentSink? Fragments { get; init; }
@@ -241,6 +244,7 @@ public sealed class BlockEnv
TcpListen = TcpListen,
PeerAddress = PeerAddress,
PeerSpkiSha256 = PeerSpkiSha256,
KnockKey = KnockKey,
Fragments = Fragments,
MaxBulkMbps = MaxBulkMbps,
MaxPacketsPerSecond = MaxPacketsPerSecond,
@@ -1,10 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
</ItemGroup>
</Project>
+18 -2
View File
@@ -5,6 +5,7 @@ using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Identity;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
@@ -46,6 +47,12 @@ public sealed class TcpLink : IDisposable
await client.ConnectAsync(env.PeerAddress!, port, ct);
long tcpUs = env.Clock.NowUs() - start;
Stream stream = client.GetStream();
if (env.KnockKey is { } knockKey)
{
// 预握手敲门:监听方不认就不会回任何字节
await stream.WriteAsync(Knock.Build(knockKey, env.Time.GetUtcNow().ToUnixTimeMilliseconds()), ct);
}
long? tlsUs = null;
if (tls)
{
@@ -115,7 +122,7 @@ public sealed class TcpConnection
public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct);
/// <summary>
/// 监听方的 TCP 数据面:每个端口一个监听器。连接的第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
/// 监听方的 TCP 数据面:每个端口一个监听器。连接先过预握手敲门,然后第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
/// 随后是认证首帧(<c>"MN" | ver | session_id | stream_id | nonce | mac</c>,随机数只能用一次),通过之后交给按 <c>stream_id</c> 登记的处理函数。
/// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。
/// </summary>
@@ -128,11 +135,13 @@ public sealed class TcpAcceptor
private readonly X509Certificate2 _certificate;
private readonly IDataSessions _sessions;
private readonly DataPlaneCounters _counters;
private readonly KnockGate _knock;
private readonly ConcurrentDictionary<uint, TcpResponder> _handlers = new();
private readonly List<TcpListener> _listeners = [];
public TcpAcceptor(IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters)
public TcpAcceptor(IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters, KnockGate knock)
{
_knock = knock;
_bind = bind;
_ports = ports;
_certificate = certificate;
@@ -214,6 +223,13 @@ public sealed class TcpAcceptor
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
authCts.CancelAfter(s_authTimeout);
// 预握手敲门:名册里的 Probe 才算得出敲门包;不通过就不写任何字节、直接断开
if (!await _knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, authCts.Token))
{
_counters.DropInvalid();
return;
}
byte[] first = new byte[1];
if (await stream.ReadAsync(first, authCts.Token) != 1)
{
@@ -1,13 +1,13 @@
using System.Globalization;
using MoShiro.Protocol;
namespace MoShiro.Agent;
namespace MoShiro.Measurement;
/// <summary>
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
/// </summary>
public sealed class HostSampler
{
@@ -18,6 +18,7 @@ public sealed class HostSampler
private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot;
private readonly string _sysRoot;
private readonly bool _native;
private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet;
@@ -25,6 +26,7 @@ public sealed class HostSampler
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{
_time = time;
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
_read = read ?? ReadProc;
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
@@ -37,7 +39,8 @@ public sealed class HostSampler
long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
(long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null;
if (cpuNow is { } cpu)
{
if (_previousCpu is { } before && cpu.Total > before.Total)
{
@@ -48,7 +51,8 @@ public sealed class HostSampler
_previousCpu = cpu;
}
if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null;
if (memoryNow is { } memory)
{
snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used;
@@ -60,11 +64,21 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used;
}
if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
(long Rx, long Tx, long? LinkMbps)? netNow = null;
if (_native)
{
netNow = WindowsNetwork();
}
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
{
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
}
if (netNow is { } net)
{
snapshot.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
snapshot.NetLinkMbps = net.LinkMbps;
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -83,6 +97,12 @@ public sealed class HostSampler
return snapshot;
}
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
public static (long Total, long Idle)? ParseCpu(string stat)
{
@@ -0,0 +1,106 @@
using System.Globalization;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 本机的公网地址:Agent 注册之后在 hello 里上报,面板用它自动填 <c>public_host</c> 和 <c>ipv6_host</c>,不需要管理员手填。
/// 只列出「别人能拨进来」的地址:IPv4 要排除私网、CGNAT、回环、链路本地;IPv6 只要全球单播(2000::/3),
/// 并且排除临时地址、tentative、deprecated 和文档/6to4/Teredo 段。读不到网卡信息时返回空,面板退回到管理员手填或观察到的来源地址。
/// </summary>
public static class LocalAddresses
{
public static IReadOnlyList<string> Sample()
{
var result = new List<string>();
HashSet<string> unusable = OperatingSystem.IsLinux() ? UnusableIpv6(Read("/proc/net/if_inet6")) : [];
try
{
foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces())
{
if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel)
{
continue;
}
foreach (UnicastIPAddressInformation unicast in adapter.GetIPProperties().UnicastAddresses)
{
IPAddress address = unicast.Address;
bool usable = address.AddressFamily == AddressFamily.InterNetwork
? PublicAddress.IsPublicIpv4(address)
: PublicAddress.IsGlobalIpv6(address) && !unusable.Contains(address.ToString()) && !IsTemporary(unicast);
if (usable && !result.Contains(address.ToString()))
{
result.Add(address.ToString());
}
}
}
}
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
{
return [];
}
// 稳定顺序:同一台机器每次上报的顺序一样,面板不会因为顺序变化而改地址
result.Sort(StringComparer.Ordinal);
return result;
}
/// <summary>Linux 的 <c>/proc/net/if_inet6</c>:临时(0x01)、dadfailed(0x08)、deprecated(0x20)、tentative(0x40)的地址不能当作对外地址。</summary>
public static HashSet<string> UnusableIpv6(string? procNetIfInet6)
{
var result = new HashSet<string>(StringComparer.Ordinal);
if (procNetIfInet6 is null)
{
return result;
}
foreach (string line in procNetIfInet6.Split('\n'))
{
string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Length != 6 || fields[0].Length != 32 || !uint.TryParse(fields[4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) || (flags & 0x69) == 0)
{
continue;
}
var bytes = new byte[16];
for (int i = 0; i < 16; i++)
{
bytes[i] = byte.Parse(fields[0].AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
result.Add(new IPAddress(bytes).ToString());
}
return result;
}
// Windows 和 macOS:随机后缀就是隐私(临时)地址
private static bool IsTemporary(UnicastIPAddressInformation unicast)
{
try
{
return OperatingSystem.IsWindows()
&& (unicast.SuffixOrigin == SuffixOrigin.Random || unicast.DuplicateAddressDetectionState is DuplicateAddressDetectionState.Tentative or DuplicateAddressDetectionState.Duplicate);
}
catch (PlatformNotSupportedException)
{
return false;
}
}
private static string? Read(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace MoShiro.Measurement;
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):Agent 和 Probe 共用。</summary>
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
}
@@ -0,0 +1,125 @@
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace MoShiro.Measurement;
/// <summary>
/// Windows 上读主机资源占用(<see cref="HostSampler"/> 在 Linux 之外的实现):CPU 时间和物理内存用两个只读的系统调用
/// (<c>GetSystemTimes</c>、<c>GlobalMemoryStatusEx</c>),网卡流量和链路速率用 .NET 自带的 <see cref="NetworkInterface"/>。
/// 和 Linux 一样只有汇总的数字:不读网卡名、MAC、IP,也不启动任何进程。
/// </summary>
[SupportedOSPlatform("windows")]
internal static partial class WindowsHost
{
private static readonly string[] s_virtualWords = ["Virtual", "vEthernet", "Hyper-V", "VMware", "VirtualBox", "TAP-", "Wintun", "WireGuard", "Npcap", "Bluetooth", "Loopback", "isatap", "Teredo"];
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
public readonly long Value => (long)(((ulong)High << 32) | Low);
}
[StructLayout(LayoutKind.Sequential)]
private struct MemoryStatus
{
public uint Length;
public uint MemoryLoad;
public ulong TotalPhys;
public ulong AvailPhys;
public ulong TotalPageFile;
public ulong AvailPageFile;
public ulong TotalVirtual;
public ulong AvailVirtual;
public ulong AvailExtendedVirtual;
}
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GlobalMemoryStatusEx(ref MemoryStatus status);
/// <summary>系统启动以来的 CPU 总时间和空闲时间(和 Linux 的 <c>/proc/stat</c> 第一行同一个意思:两次采样相减得到占用)。</summary>
public static (long Total, long Idle)? Cpu()
{
try
{
if (!GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user))
{
return null;
}
// 内核时间里已经包含了空闲时间
return (kernel.Value + user.Value, idle.Value);
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
{
return null;
}
}
/// <summary>物理内存的总量和已用(总量减去可用)。</summary>
public static (long Total, long Used)? Memory()
{
try
{
var status = new MemoryStatus { Length = (uint)Marshal.SizeOf<MemoryStatus>() };
if (!GlobalMemoryStatusEx(ref status) || status.TotalPhys == 0)
{
return null;
}
return ((long)status.TotalPhys, (long)(status.TotalPhys - Math.Min(status.AvailPhys, status.TotalPhys)));
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
{
return null;
}
}
/// <summary>所有物理网卡(在用的,排除回环、隧道、虚拟网卡)的累计收发字节,以及链路速率之和(Mbit/s;有一块读不到速率就是 null)。</summary>
public static (long Rx, long Tx, long? LinkMbps)? Network()
{
try
{
long rx = 0;
long tx = 0;
long mbps = 0;
bool speedKnown = true;
bool any = false;
foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel
|| s_virtualWords.Any(w => nic.Description.Contains(w, StringComparison.OrdinalIgnoreCase) || nic.Name.Contains(w, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
IPInterfaceStatistics stats = nic.GetIPStatistics();
rx += stats.BytesReceived;
tx += stats.BytesSent;
any = true;
if (nic.Speed > 0)
{
mbps += nic.Speed / 1_000_000;
}
else
{
speedKnown = false;
}
}
return any ? (rx, tx, speedKnown && mbps > 0 ? mbps : null) : null;
}
catch (NetworkInformationException)
{
return null;
}
}
}
@@ -0,0 +1,153 @@
using System.Globalization;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 节点的「更新」:面板建议、请求,节点<b>只负责有序退出</b>,换新版本由外面的服务管理器完成——
/// 例如 Podman 的 Quadlet 单元写了 <c>Pull=always</c>,每次启动都先从镜像仓库拉最新的镜像,所以退出再被拉起就是更新。
/// 节点自己不下载、不安装、不执行任何东西,所以不需要验证什么:最多让面板(管理员)请求一次重启,和定时重启是同一种能力。
/// <list type="bullet">
/// <item>只有设了环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用(Quadlet 模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新,所以没有启用就不报告更新状态。</item>
/// <item>策略由环境变量 <c>MOSHIRO_UPDATE_POLICY</c> 决定:<c>auto</c>(默认)发现新版本就自己退出重启,<c>manual</c> 等管理端请求。自动更新有保护:
/// 进程至少运行 <see cref="MinimumUptime"/> 才会自动退出,新镜像没拉到(标签没变、仓库还没更新)最多每个这么久重启一次,不会陷入重启循环。</item>
/// <item>面板转来的发布清单(面板登记时已验过 CI 签名)只用来显示「有没有新版本」:组件对、比这个程序新(seq 晚于构建时间)、tag 和提交不同。</item>
/// </list>
/// </summary>
public sealed class RestartUpdater
{
public const string EnvironmentVariable = "MOSHIRO_UPDATE";
public const string PolicyVariable = "MOSHIRO_UPDATE_POLICY";
/// <summary>自动更新之前,进程至少要运行这么久。</summary>
public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10);
private const int MaxManifestBytes = 4096;
private readonly string _component;
private readonly Action<string> _requestRestart;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private readonly string _tag;
private readonly string _commit;
private readonly long _builtAt;
private readonly object _gate = new();
private readonly bool _auto;
private int _autoScheduled;
private readonly long _startedAt;
private UpdateStatus _status;
/// <param name="tag">这个程序的构建标签、提交和构建时间(秒):默认取 <see cref="BuildInfo"/>,测试可以传入。</param>
public RestartUpdater(string component, Action<string> requestRestart, TimeProvider time, INodeLog log, string? tag = null, string? commit = null, long? builtAtEpoch = null, bool auto = false)
{
_auto = auto;
_startedAt = time.GetTimestamp();
_component = component;
_requestRestart = requestRestart;
_time = time;
_log = log;
_tag = tag ?? BuildInfo.Tag;
_commit = commit ?? BuildInfo.Commit;
_builtAt = builtAtEpoch ?? (DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) ? built.ToUnixTimeSeconds() : 0);
_status = new UpdateStatus { State = "up_to_date", At = Now() };
}
/// <summary>环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用;否则返回 null,节点不报告更新状态。</summary>
public static RestartUpdater? FromEnvironment(string component, Action<string> requestRestart, TimeProvider time, INodeLog log) =>
string.Equals(Environment.GetEnvironmentVariable(EnvironmentVariable), "restart", StringComparison.Ordinal)
? new RestartUpdater(component, requestRestart, time, log, auto: !string.Equals(Environment.GetEnvironmentVariable(PolicyVariable), "manual", StringComparison.OrdinalIgnoreCase))
: null;
/// <summary>面板转来的发布清单:只用来判断有没有新版本。</summary>
public void Offer(UpdateOfferParams offer)
{
if (offer.Manifest.Length is 0 or > MaxManifestBytes)
{
_log.Warn("ignoring an update offer of an unexpected size");
return;
}
try
{
using JsonDocument document = JsonDocument.Parse(offer.Manifest);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object || Text(root, "component") != _component || !root.TryGetProperty("seq", out JsonElement seqValue) || !seqValue.TryGetInt64(out long seq))
{
return;
}
string tag = Text(root, "tag");
string commit = Text(root, "commit");
bool same = (_tag.Length > 0 && tag == _tag) || (_commit.Length > 0 && commit.Length > 0 && commit == _commit);
bool available;
lock (_gate)
{
_status = same || seq <= _builtAt
? new UpdateStatus { State = "up_to_date", At = Now() }
: new UpdateStatus
{
State = "available",
Available = new UpdateAvailable { Tag = tag, Version = Text(root, "version"), Digest = Text(root, "digest"), Seq = seq },
At = Now(),
};
available = _status.State == "available";
}
if (available && _auto && Interlocked.Exchange(ref _autoScheduled, 1) == 0)
{
_ = ApplyAfterMinimumUptimeAsync();
}
}
catch (JsonException)
{
_log.Warn("ignoring an update offer that is not valid JSON");
}
}
/// <summary>自动更新:等进程运行够久再退出(刚启动就发现新版本,通常是重启之后标签还没变,立刻再退出会变成重启循环)。</summary>
private async Task ApplyAfterMinimumUptimeAsync()
{
TimeSpan wait = MinimumUptime - _time.GetElapsedTime(_startedAt);
if (wait > TimeSpan.Zero)
{
_log.Info("update available, restarting automatically later", ("in", wait.ToString("c", CultureInfo.InvariantCulture)));
await Task.Delay(wait, _time);
}
RequestApply();
}
/// <summary>面板请求更新:有新版本才退出(由外面的服务管理器拉起并拉取最新镜像)。</summary>
public bool RequestApply()
{
lock (_gate)
{
if (_status.State != "available")
{
return false;
}
_status = new UpdateStatus { State = "applying", Available = _status.Available, At = Now() };
}
_log.Info("update requested, restarting so the supervisor pulls the latest image");
_requestRestart("update");
return true;
}
/// <summary>随心跳上报的状态。</summary>
public UpdateStatus Status()
{
lock (_gate)
{
return new UpdateStatus { State = _status.State, Current = _status.Current, Available = _status.Available, Error = _status.Error, At = _status.At };
}
}
private long Now() => _time.GetUtcNow().ToUnixTimeSeconds();
private static string Text(JsonElement root, string name) =>
root.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
}
@@ -1,100 +0,0 @@
using System.Text;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 节点进程和宿主机更新器之间的文件交换(容器里 <c>/etc/moshiro</c>,也就是数据目录):
/// 节点把面板发来的清单和签名写进 <c>update/</c>,把「更新」请求写成 <c>update/apply.request</c>;更新器验签并更新,状态由宿主更新器写在只读挂载的 <c>update-status/status.json</c>。
/// 节点进程不执行任何命令,也不信任清单的内容(验签在宿主机上)。没有安装更新器时目录写不了或没人读,调用只是返回 false。
/// </summary>
public sealed class UpdateBridge
{
private const int MaxManifestBytes = 4096;
private const int MaxSignatureBytes = 512;
private const int MaxStatusBytes = 4096;
private readonly string _updateDirectory;
private readonly string _statusFile;
private readonly Action<string, byte[]> _write;
private readonly INodeLog _log;
/// <param name="write">写数据目录里的文件(相对路径,原子替换):由节点传入 <c>ConfigStore.Write</c>——常驻进程里只有它可以写文件。</param>
public UpdateBridge(string dataDirectory, Action<string, byte[]> write, INodeLog log)
{
_updateDirectory = Path.Combine(dataDirectory, "update");
_statusFile = Path.Combine(dataDirectory, "update-status", "status.json");
_write = write;
_log = log;
}
/// <summary>放下面板发来的清单和签名(和已有的一样就不动,免得每次重连都惊动更新器)。先写清单、后写签名:更新器监视的是签名文件。</summary>
public bool WriteOffer(UpdateOfferParams offer)
{
byte[] manifest = Encoding.UTF8.GetBytes(offer.Manifest);
byte[] signature = Encoding.UTF8.GetBytes(offer.Signature);
if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes)
{
_log.Warn("ignoring an update offer of an unexpected size");
return false;
}
try
{
string manifestPath = Path.Combine(_updateDirectory, "offer.manifest");
string signaturePath = Path.Combine(_updateDirectory, "offer.sig");
if (Same(manifestPath, manifest) && Same(signaturePath, signature))
{
return true;
}
_write("update/offer.manifest", manifest);
_write("update/offer.sig", signature);
_log.Info("update offer received");
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not store the update offer (is the updater installed?)", ("err", ex.Message));
return false;
}
}
/// <summary>请求更新:写一个空的 <c>apply.request</c>(更新器验签通过、清单更新才会真的动手)。</summary>
public bool RequestApply()
{
try
{
_write("update/apply.request", []);
_log.Info("update requested");
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not request the update (is the updater installed?)", ("err", ex.Message));
return false;
}
}
/// <summary>更新器的状态;没有安装更新器、文件不存在或内容不对时返回 null。</summary>
public UpdateStatus? ReadStatus()
{
try
{
if (!File.Exists(_statusFile) || new FileInfo(_statusFile).Length > MaxStatusBytes)
{
return null;
}
return JsonSerializer.Deserialize(File.ReadAllBytes(_statusFile), MoShiroJsonContext.Default.UpdateStatus);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
return null;
}
}
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
}
@@ -3,7 +3,6 @@ namespace MoShiro.Protocol;
public static class ProtocolVersion
{
public const int Container = 1;
public const int Wire = 1;
}
public static class ProtocolLimits
@@ -39,5 +38,4 @@ public static class MoShiroCapabilities
{
public const string Base = "moshiro.v1";
public const string KindsPrefix = "moshiro.kinds:";
public const string BlocksPrefix = "moshiro.blocks:";
}
@@ -0,0 +1,46 @@
using System.Net;
using System.Net.Sockets;
namespace MoShiro.Protocol;
/// <summary>哪些地址算「公网上能拨进来的地址」:Agent 上报本机地址和面板自动识别共用同一套判断。</summary>
public static class PublicAddress
{
public static bool IsPublicIpv4(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetwork)
{
return false;
}
byte[] b = address.GetAddressBytes();
return !(b[0] is 0 or 10 or 127 or >= 224
|| (b[0] == 100 && b[1] is >= 64 and <= 127)
|| (b[0] == 169 && b[1] == 254)
|| (b[0] == 172 && b[1] is >= 16 and <= 31)
|| (b[0] == 192 && b[1] == 168)
|| (b[0] == 192 && b[1] == 0 && b[2] is 0 or 2)
|| (b[0] == 198 && b[1] is 18 or 19)
|| (b[0] == 198 && b[1] == 51 && b[2] == 100)
|| (b[0] == 203 && b[1] == 0 && b[2] == 113));
}
public static bool IsGlobalIpv6(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetworkV6 || address.IsIPv4MappedToIPv6)
{
return false;
}
byte[] b = address.GetAddressBytes();
if ((b[0] & 0xE0) != 0x20)
{
return false;
}
// 2001:db8::/32 文档、2002::/16 6to4、2001::/32 Teredo
return !((b[0] == 0x20 && b[1] == 0x01 && b[2] == 0x0d && b[3] == 0xb8)
|| (b[0] == 0x20 && b[1] == 0x02)
|| (b[0] == 0x20 && b[1] == 0x01 && b[2] == 0 && b[3] == 0));
}
}
@@ -34,10 +34,10 @@ public static class MoShiroMethodTable
MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
// 节点面:Agent → Backend
// 节点面:节点 → Backend
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总
// 对等面:Probe(拨号方)→ Agent(监听方)
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
@@ -149,9 +149,9 @@ public sealed class AgentEndpoint
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [];
/// <summary>Agent 面向 Probe 的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("spki")]
public string Spki { get; set; } = "";
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
@@ -164,6 +164,9 @@ public sealed class ProbeAgentList
[JsonPropertyName("agents")]
public List<AgentEndpoint> Agents { get; set; } = [];
[JsonPropertyName("trust_chain")]
public List<SignedStatementDto>? TrustChain { get; set; }
}
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
@@ -303,6 +306,10 @@ public sealed class ProbeHeartbeatExtra
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta
{
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -313,6 +320,10 @@ public sealed class ProbeHelloMeta
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
public sealed class AgentHelloMeta
{
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -323,8 +334,13 @@ public sealed class AgentHelloMeta
[JsonPropertyName("platform")]
public PlatformInfo? Platform { get; set; }
[JsonPropertyName("tls_spki")]
public string? TlsSpki { get; set; }
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64);面板据此得到证书指纹,并下发给 Probe 用来固定证书和派生敲门密钥。</summary>
[JsonPropertyName("tls_pubkey")]
public string? TlsPublicKey { get; set; }
/// <summary>本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 <c>public_host</c> 和 <c>ipv6_host</c>,管理员手填的优先。</summary>
[JsonPropertyName("addresses")]
public List<string>? Addresses { get; set; }
[JsonPropertyName("probe_tcp_port")]
public int? ProbeTcpPort { get; set; }
@@ -56,6 +56,10 @@ public sealed class TaskSet
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
[JsonPropertyName("peers")]
public List<PeerSpec> Peers { get; set; } = [];
/// <summary>操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。</summary>
[JsonPropertyName("trust_chain")]
public List<SignedStatementDto>? TrustChain { get; set; }
}
public static class TaskSchedule
@@ -0,0 +1,36 @@
using MoShiro.Measurement;
namespace MoShiro.Measurement.Tests;
public sealed class LocalAddressesTests
{
[Fact]
public void Linux_TemporaryTentativeDeprecatedAndFailedAddressesAreNotUsable()
{
const string text =
"2409c0d0000000000000000000000001 02 40 00 80 eth0\n" + // 稳定地址:可用
"2409c0d0000000000000000000000002 02 40 00 01 eth0\n" + // 临时(隐私)地址
"2409c0d0000000000000000000000003 02 40 00 40 eth0\n" + // tentative
"2409c0d0000000000000000000000004 02 40 00 20 eth0\n" + // deprecated
"2409c0d0000000000000000000000005 02 40 00 08 eth0\n"; // dad 失败
HashSet<string> unusable = LocalAddresses.UnusableIpv6(text);
Assert.DoesNotContain("2409:c0d0::1", unusable);
Assert.Contains("2409:c0d0::2", unusable);
Assert.Contains("2409:c0d0::3", unusable);
Assert.Contains("2409:c0d0::4", unusable);
Assert.Contains("2409:c0d0::5", unusable);
Assert.Empty(LocalAddresses.UnusableIpv6(null));
}
[Fact]
public void Sample_NeverReturnsLoopbackPrivateOrLinkLocalAddresses()
{
foreach (string text in LocalAddresses.Sample())
{
var address = System.Net.IPAddress.Parse(text);
Assert.True(address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
? MoShiro.Protocol.PublicAddress.IsPublicIpv4(address)
: MoShiro.Protocol.PublicAddress.IsGlobalIpv6(address));
}
}
}
@@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,113 @@
using Fleet.Core;
using Microsoft.Extensions.Time.Testing;
using MoShiro.Protocol;
namespace MoShiro.Measurement.Tests;
public sealed class RestartUpdaterTests
{
private readonly List<string> _restarts = [];
private RestartUpdater Create(string tag = "dev-aaa", string commit = "aaa", long builtAt = 1_000) =>
new("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance, tag, commit, builtAt);
private static UpdateOfferParams Offer(string tag, long seq, string component = "agent", string commit = "bbb") =>
new() { Manifest = $"{{\"component\":\"{component}\",\"tag\":\"{tag}\",\"version\":\"0.4.0\",\"commit\":\"{commit}\",\"digest\":\"sha256:{new string('a', 64)}\",\"seq\":{seq}}}", Signature = "ignored" };
[Fact]
public void ANewerRelease_IsAvailable_AndOnlyARequestRestartsTheNode()
{
RestartUpdater updater = Create();
updater.Offer(Offer("dev-bbb", 2_000));
UpdateStatus status = updater.Status();
Assert.Equal("available", status.State);
Assert.Equal("dev-bbb", status.Available!.Tag);
Assert.Equal(2_000, status.Available.Seq);
Assert.Empty(_restarts);
Assert.True(updater.RequestApply());
Assert.Equal(["update"], _restarts);
Assert.Equal("applying", updater.Status().State);
}
[Fact]
public void TheReleaseThatIsAlreadyRunning_IsUpToDate_ByTagOrByCommit()
{
RestartUpdater updater = Create();
updater.Offer(Offer("dev-aaa", 2_000));
Assert.Equal("up_to_date", updater.Status().State);
updater.Offer(Offer("dev-other-tag", 2_000, commit: "aaa"));
Assert.Equal("up_to_date", updater.Status().State);
Assert.False(updater.RequestApply());
Assert.Empty(_restarts);
}
[Fact]
public void AnOfferOlderThanThisBuild_OrForAnotherComponent_IsNotAnUpdate()
{
RestartUpdater updater = Create(builtAt: 5_000);
updater.Offer(Offer("dev-bbb", 2_000));
Assert.Equal("up_to_date", updater.Status().State);
updater.Offer(Offer("dev-ccc", 9_000, component: "probe"));
Assert.Equal("up_to_date", updater.Status().State);
Assert.False(updater.RequestApply());
}
[Fact]
public void GarbageOffers_AreIgnored()
{
RestartUpdater updater = Create();
updater.Offer(new UpdateOfferParams { Manifest = "not json", Signature = "x" });
updater.Offer(new UpdateOfferParams { Manifest = new string('x', 5000), Signature = "x" });
updater.Offer(new UpdateOfferParams { Manifest = "{\"component\":\"agent\"}", Signature = "x" });
Assert.Equal("up_to_date", updater.Status().State);
Assert.False(updater.RequestApply());
}
[Fact]
public async Task TheAutoPolicy_RestartsByItself_ButNotBeforeTheMinimumUptime()
{
var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000));
var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: true);
updater.Offer(Offer("dev-bbb", 2_000));
updater.Offer(Offer("dev-bbb", 2_000)); // 重连时面板会再发一次:不能排两个
Assert.Empty(_restarts);
time.Advance(TimeSpan.FromMinutes(9));
await Task.Delay(50);
Assert.Empty(_restarts);
time.Advance(TimeSpan.FromMinutes(2));
await WaitAsync(() => _restarts.Count == 1);
Assert.Equal("applying", updater.Status().State);
await Task.Delay(50);
Assert.Single(_restarts);
}
[Fact]
public async Task TheManualPolicy_NeverRestartsByItself()
{
var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000));
var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: false);
updater.Offer(Offer("dev-bbb", 2_000));
time.Advance(TimeSpan.FromHours(1));
await Task.Delay(50);
Assert.Empty(_restarts);
Assert.Equal("available", updater.Status().State);
}
private static async Task WaitAsync(Func<bool> condition)
{
for (int i = 0; i < 100 && !condition(); i++)
{
await Task.Delay(20);
}
Assert.True(condition());
}
[Fact]
public void WithoutTheEnvironmentVariable_ThereIsNoUpdater()
{
Assert.Null(Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable));
Assert.Null(RestartUpdater.FromEnvironment("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance));
}
}
@@ -0,0 +1,35 @@
using System.Net;
using MoShiro.Protocol;
namespace MoShiro.Protocol.Tests;
public sealed class PublicAddressTests
{
[Theory]
[InlineData("8.8.8.8", true)]
[InlineData("23.176.40.145", true)]
[InlineData("10.1.2.3", false)]
[InlineData("172.16.0.1", false)]
[InlineData("172.32.0.1", true)]
[InlineData("192.168.2.162", false)]
[InlineData("100.64.0.1", false)]
[InlineData("169.254.1.1", false)]
[InlineData("127.0.0.1", false)]
[InlineData("203.0.113.10", false)]
[InlineData("224.0.0.1", false)]
public void Ipv4_OnlyPublicAddressesCountAsReachable(string text, bool expected) =>
Assert.Equal(expected, PublicAddress.IsPublicIpv4(IPAddress.Parse(text)));
[Theory]
[InlineData("2409:8a55:6c45:62d1::69d", true)]
[InlineData("2606:4700:4700::1111", true)]
[InlineData("fd00::1", false)]
[InlineData("fe80::1", false)]
[InlineData("::1", false)]
[InlineData("2001:db8::1", false)]
[InlineData("2002:c000:204::1", false)]
[InlineData("2001:0:4136:e378::1", false)]
[InlineData("::ffff:8.8.8.8", false)]
public void Ipv6_OnlyGlobalUnicastCountsAsReachable(string text, bool expected) =>
Assert.Equal(expected, PublicAddress.IsGlobalIpv6(IPAddress.Parse(text)));
}
@@ -1,4 +1,5 @@
using Fleet.Core;
using Fleet.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -17,50 +18,67 @@ public static class AgentEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/agents", ListAsync)
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403);
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapGet("/agents/{id}", GetAsync)
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRead);
group.MapPatch("/agents/{id}", PatchAsync)
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412)
.Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetEnroll);
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
.Returns<AgentDto>().ProducesProblems(401, 403, 404);
.Returns<AgentDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetEnroll);
group.MapDelete("/agents/{id}", DeleteAsync)
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetRevoke);
group.MapGet("/join-tokens", ListTokensAsync)
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapPost("/join-tokens", CreateTokenAsync)
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
.Requires(Capability.FleetEnroll);
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetEnroll);
group.MapGet("/probes", ListProbesAsync)
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapGet("/probes/{id}", GetProbeAsync)
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRead);
group.MapPatch("/probes/{id}", PatchProbeAsync)
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
.Requires(Capability.FleetWrite);
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
.Returns<ProbeDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRevoke);
group.MapDelete("/probes/{id}", DeleteProbeAsync)
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetRevoke);
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetWrite);
}
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
@@ -81,7 +99,7 @@ public static class AgentEndpoints
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
{
Result<AgentView> result = await agents.PatchAsync(
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicHostAuto = request.PublicHostAuto, Ipv6HostAuto = request.Ipv6HostAuto, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
if (!result.TryGet(out AgentView? view, out FleetError? error))
{
return Problems.From(http, error);
@@ -112,11 +130,23 @@ public static class AgentEndpoints
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options)
{
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
Result<string[]> owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers);
if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError))
{
return Problems.From(http, ownerError);
}
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole), InstallScripts.QuadletSha256(request.Role ?? JoinTokenService.AgentRole));
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
Result<CreatedJoinToken> result = await tokens.CreateAsync(
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole), baseUrl, http.RequestAborted);
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
{
return Problems.From(http, error);
@@ -124,7 +154,7 @@ public static class AgentEndpoints
http.Response.Headers.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/join-tokens";
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows), new QuadletInstallDto(created.Quadlet.Env, created.Quadlet.Url, created.Quadlet.Sha256, created.Quadlet.Command)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
@@ -149,15 +179,32 @@ public static class AgentEndpoints
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct)
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct)
{
if (request.Name is null)
if (request.Name is null && request.Isp is null)
{
return Problems.From(http, ValidationError.Single("name", "is required"));
return Problems.From(http, ValidationError.Single("name", "name or isp is required"));
}
Result<ProbeView> result = await probes.RenameAsync(id, request.Name, ct);
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
{
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
}
Result<ProbeView> result = request.Name is null
? (await probes.GetAsync(id, ct) is { } existing ? Result<ProbeView>.Ok(existing) : Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found"))
: await probes.RenameAsync(id, request.Name, ct);
if (!result.TryGet(out ProbeView? view, out FleetError? error))
{
return Problems.From(http, error);
}
if (request.Isp is not null)
{
await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct);
}
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto);
}
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
public static class AuditEndpoints
{
private const string Tag = "admin-audit";
/// <summary>handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。</summary>
public const string DetailItem = "moshiro.audit.detail";
public static void Map(RouteGroupBuilder guarded) =>
guarded.MapGet("/audit", ListAsync)
.Api("audit_list", "Audit log of admin actions, newest first", Tag).Returns<PageDto<AuditDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.AuditRead);
public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail;
private static async Task<IResult> ListAsync(HttpContext http, AuditService audit, string? actor, string? action, int? limit, string? cursor)
{
long? before = null;
if (cursor is not null)
{
if (!long.TryParse(cursor, out long parsed))
{
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
}
before = parsed;
}
int size = Math.Clamp(limit ?? 50, 1, 500);
IReadOnlyList<AuditRecord> rows = await audit.ListAsync(new AuditQuery(before, size + 1, string.IsNullOrEmpty(actor) ? null : actor, string.IsNullOrEmpty(action) ? null : action), http.RequestAborted);
bool more = rows.Count > size;
IReadOnlyList<AuditRecord> page = [.. rows.Take(size)];
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
return TypedResults.Json(new PageDto<AuditDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto);
}
}
@@ -16,36 +16,104 @@ public static class AuthEndpoints
public static void MapLogin(RouteGroupBuilder open)
{
open.MapPost("/auth/login", LoginAsync)
.AllowsAnonymous()
.Api("auth_login", "Sign in with a username and password", Tag)
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
open.MapGet("/auth/setup", SetupStatusAsync)
.AllowsAnonymous()
.Api("setup_status", "Whether the panel has no users yet and still has to be claimed", Tag)
.Returns<SetupStatusDto>();
open.MapPost("/auth/setup", SetupAsync)
.AllowsAnonymous()
.Api("setup_complete", "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)", Tag)
.Returns<LoginResponse>(StatusCodes.Status201Created).ProducesProblems(400, 401, 409, 429);
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
.AllowsAnonymous()
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(429);
open.MapPost("/auth/passkey/login", PasskeyLoginAsync)
.AllowsAnonymous()
.Api("auth_passkey_login", "Sign in with a passkey assertion; same response as the password sign-in", Tag)
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
}
public static void MapSession(RouteGroupBuilder guarded)
{
guarded.MapPost("/auth/logout", LogoutAsync)
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
.Requires(Capability.Authenticated);
guarded.MapGet("/auth/passkeys", ListPasskeysAsync)
.Api("auth_passkeys_list", "The signed-in user's passkeys", Tag)
.Returns<PageDto<PasskeyDto>>().ProducesProblems(401)
.Requires(Capability.Authenticated);
guarded.MapPost("/auth/passkeys/options", PasskeyCreationOptionsAsync)
.Api("auth_passkeys_options", "A challenge for registering a new passkey (the user must be signed in)", Tag)
.Returns<PasskeyCreationOptionsDto>().ProducesProblems(400, 401, 403)
.Requires(Capability.Authenticated);
guarded.MapPost("/auth/passkeys", RegisterPasskeyAsync)
.Api("auth_passkeys_register", "Register a passkey from a WebAuthn attestation (ES256, user verification required)", Tag)
.Returns<PasskeyDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.Authenticated);
guarded.MapDelete("/auth/passkeys/{id}", DeletePasskeyAsync)
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
.Requires(Capability.Authenticated);
guarded.MapGet("/auth/me", Me)
.Api("auth_me", "The current identity", Tag)
.Returns<MeDto>().ProducesProblems(401);
.Returns<MeDto>().ProducesProblems(401)
.Requires(Capability.Authenticated);
}
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
string source = http.ClientAddress();
if (!limiter.TryAcquire(source, out int retryAfter))
{
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
}
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
string remote = source;
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "auth.login", null, null, remote, false, http.RequestAborted);
return Problems.From(http, error);
}
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
bool returnSession = request.ReturnSession == true;
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted);
return IssueSession(http, login, request.ReturnSession == true, options);
}
private static async Task<IResult> SetupStatusAsync(SetupService setup, CancellationToken ct)
{
return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto);
}
private static async Task<IResult> SetupAsync(HttpContext http, SetupRequest request, SetupService setup, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok, "setup:");
if (!ok)
{
return limit;
}
string remote = http.ClientAddress();
Result<LoginResult> result = await setup.CompleteAsync(request.ClaimCode, request.Username ?? "", request.Password ?? "", http.RequestAborted);
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "setup.complete", null, null, remote, false, http.RequestAborted);
return Problems.From(http, error);
}
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "setup.complete", null, null, remote, true, http.RequestAborted);
Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> session = IssueSession(http, login, request.ReturnSession == true, options);
return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created);
}
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
{
if (!returnSession)
{
http.Response.Cookies.Append(
@@ -65,6 +133,98 @@ public static class AuthEndpoints
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
}
/// <summary>这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。</summary>
internal static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options)
{
string own = $"{http.Request.Scheme}://{http.Request.Host}";
Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null;
string rpId = !string.IsNullOrWhiteSpace(options.WebAuthnRpId) ? options.WebAuthnRpId.Trim() : configured?.Host ?? http.Request.Host.Host;
IEnumerable<string> origins = !string.IsNullOrWhiteSpace(options.WebAuthnOrigins)
? options.WebAuthnOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(o => o.TrimEnd('/'))
: [configured is null ? own : configured.GetLeftPart(UriPartial.Authority)];
return new PasskeyContext(rpId, new HashSet<string>(origins, StringComparer.Ordinal));
}
/// <summary>限流。<paramref name="scope"/> 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。</summary>
private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null)
{
string source = scope + (http.ClientAddress());
ok = limiter.TryAcquire(source, out int retryAfter, limit);
return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
}
/// <summary>取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。</summary>
private const int PasskeyOptionsPerMinute = 60;
private static IResult PasskeyLoginOptions(HttpContext http, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok, "passkey-options:", limit: PasskeyOptionsPerMinute);
if (!ok)
{
return limit;
}
RequestOptions o = passkeys.BeginLogin(PasskeyContextOf(http, options));
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto);
}
private static async Task<IResult> PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok);
if (!ok)
{
return limit;
}
Result<LoginResult> result = await passkeys.LoginAsync(
new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
string? remote = http.ClientAddress();
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted);
return Problems.From(http, error);
}
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Passkey, "auth.login", null, null, remote, true, http.RequestAborted);
return IssueSession(http, login, request.ReturnSession == true, options);
}
private static async Task<IResult> ListPasskeysAsync(HttpContext http, PasskeyService passkeys)
{
string userId = http.GetPrincipal().UserId ?? "";
IReadOnlyList<PasskeyRecord> list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted);
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
}
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
{
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PasskeyCreationOptionsDto(o.Challenge, o.RpId, o.RpName, o.UserId, o.UserName, o.ExcludeCredentials, o.TimeoutMs), ApiJsonContext.Default.PasskeyCreationOptionsDto);
}
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
{
Result<PasskeyRecord> result = await passkeys.RegisterAsync(
http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted);
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created)
: Problems.From(http, error);
}
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
{
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
? TypedResults.NoContent()
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
}
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
{
string? bearer = PrincipalResolver.BearerOf(http);
@@ -85,7 +245,7 @@ public static class AuthEndpoints
{
Principal principal = http.GetPrincipal();
return TypedResults.Json(
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null),
ApiJsonContext.Default.MeDto);
}
}
@@ -17,33 +17,57 @@ public static class BuildEndpoints
{
group.MapGet("/build-info", GetAsync)
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapGet("/releases", ListReleasesAsync)
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403);
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409);
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
.Requires(Capability.UpdateConfirm);
group.MapGet("/backend/update", BackendUpdateAsync)
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403);
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412);
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412)
.Requires(Capability.UpdateConfirm);
group.MapGet("/backend/update/live", LiveBackendUpdateAsync)
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403);
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
}
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
public static void MapPublish(RouteGroupBuilder group)
/// <summary>
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
/// </summary>
public static void MapOffer(IEndpointRouteBuilder app)
{
group.MapPost("/releases", PublishReleaseAsync)
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
app.MapPost("/api/release/offer", OfferReleaseAsync)
.Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release")
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, ReleaseKeyService keys, PublishReleaseRequest request)
{
string manifest = request.Manifest ?? "";
string signature = request.Signature ?? "";
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|| !await keys.VerifyAsync(manifest, signature, http.RequestAborted))
{
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
}
return await PublishReleaseAsync(http, releases, request);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, NodeOwnerService owners, CancellationToken ct)
{
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
var items = (await nodes.ListAsync(ct))
.OrderBy(n => n.Kind, StringComparer.Ordinal)
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId)))))
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
@@ -16,14 +16,18 @@ public static class DataEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/raw/chunks", ListChunksAsync)
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.DataRead);
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410);
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410)
.Requires(Capability.DataRead);
group.MapGet("/events", ListEventsAsync)
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.EventsRead);
group.MapGet("/analysis/recompute", RecomputeAsync)
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403)
.Requires(Capability.DataWrite);
}
private static async Task<IResult> ListChunksAsync(
@@ -15,9 +15,11 @@ public static class LinkEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/links", ListAsync)
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403);
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404);
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404)
.Requires(Capability.FleetWrite);
}
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
@@ -0,0 +1,54 @@
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>可信资产来源:官方发布密钥(只读)和可选的自有发布公钥(默认没有)。改动写审计。</summary>
public static class ReleaseKeyEndpoints
{
private const string Tag = "admin-release-key";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/release-key", GetAsync)
.Api("release_key_get", "The release keys this panel trusts: the built-in official key and the optional own key", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapPut("/release-key", SetAsync)
.Api("release_key_set", "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(400, 401, 403)
.Requires(Capability.UpdateConfirm);
group.MapDelete("/release-key", ClearAsync)
.Api("release_key_clear", "Stop trusting your own release key; manifests only it signed are dropped", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateConfirm);
}
private static async Task<IResult> GetAsync(ReleaseKeyService keys, CancellationToken ct) => Json(await keys.GetAsync(ct));
private static async Task<IResult> SetAsync(HttpContext http, ReleaseKeyService keys, SetReleaseKeyRequest request)
{
Result<ReleaseKeys> result = await keys.SetCustomAsync(request.PublicKey, http.RequestAborted);
if (!result.TryGet(out ReleaseKeys? view, out FleetError? error))
{
return Problems.From(http, error);
}
http.SetDetail("own release key " + view.Custom!.Fingerprint);
return Json(view);
}
private static async Task<IResult> ClearAsync(HttpContext http, ReleaseKeyService keys, ReleaseService releases)
{
await keys.ClearCustomAsync(http.RequestAborted);
await releases.PurgeUnverifiedAsync(http.RequestAborted);
http.SetDetail("own release key removed");
return Json(await keys.GetAsync(http.RequestAborted));
}
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<ReleaseKeysDto> Json(ReleaseKeys keys) =>
TypedResults.Json(new ReleaseKeysDto(new ReleaseKeyDto(keys.Official.Fingerprint, keys.Official.PublicKey), keys.Custom is null ? null : new ReleaseKeyDto(keys.Custom.Fingerprint, keys.Custom.PublicKey)), ApiJsonContext.Default.ReleaseKeysDto);
}
@@ -18,33 +18,54 @@ public static class SystemEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/api-tokens", ListTokensAsync)
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403)
.Requires(Capability.KeysRead);
group.MapPost("/api-tokens", CreateTokenAsync)
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
.Api("api_tokens_create", "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", TokensTag)
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
.Requires(Capability.KeysManage);
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.KeysManage);
group.MapGet("/capabilities", CapabilitiesAsync)
.Api("capabilities_list", "The capability catalog: groups, which ones change trust, which are reserved, and the presets", TokensTag)
.Returns<CapabilityCatalogDto>().ProducesProblems(401, 403)
.Requires(Capability.KeysRead);
group.MapGet("/settings", ListSettingsAsync)
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403)
.Requires(Capability.SettingsRead);
group.MapPatch("/settings", PatchSettingsAsync)
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.SettingsWrite);
group.MapGet("/users", ListUsersAsync)
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
.Requires(Capability.AccountsRead);
group.MapPost("/users", CreateUserAsync)
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage);
group.MapPatch("/users/{id}", ChangePasswordAsync)
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403)
.Requires(Capability.AccountsManage);
}
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<CapabilityCatalogDto> CapabilitiesAsync() =>
TypedResults.Json(
new CapabilityCatalogDto(
[.. CapabilityCatalog.Groups.Select(g => new CapabilityGroupDto(g, [.. CapabilityCatalog.All.Where(c => c.Group == g).Select(c => new CapabilityDto(c.Id, c.Trust, c.Reserved, c.Implies))]))],
[.. CapabilityCatalog.Presets.Select(p => new CapabilityPresetDto(p.Id, p.Capabilities))]),
ApiJsonContext.Default.CapabilityCatalogDto);
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
{
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
{
return Problems.From(http, error);
@@ -16,26 +16,34 @@ public static class TaskEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/tasks", ListAsync)
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403)
.Requires(Capability.TasksRead);
group.MapPost("/tasks", CreateAsync)
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404)
.Requires(Capability.TasksWrite);
group.MapGet("/tasks/{id}", GetAsync)
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.TasksRead);
group.MapPatch("/tasks/{id}", PatchAsync)
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412)
.Requires(Capability.TasksWrite);
group.MapDelete("/tasks/{id}", DeleteAsync)
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.TasksWrite);
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409)
.Requires(Capability.TasksWrite);
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403)
.Requires(Capability.TasksRead);
group.MapPost("/tests/{id}/cancel", CancelAsync)
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.TasksWrite);
}
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
@@ -0,0 +1,72 @@
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>
/// 信任根(docs/security/access.md 第 8 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
/// </summary>
public static class TrustEndpoints
{
private const string Tag = "admin-trust";
public static void Map(RouteGroupBuilder guarded)
{
guarded.MapGet("/trust", StateAsync)
.Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag)
.Returns<TrustStateDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
guarded.MapPost("/trust", CommitAsync)
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag)
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage);
}
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
{
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
http.Response.Headers.CacheControl = "no-store";
string sh = InstallScripts.Sha256(InstallScripts.Sh);
string ps1 = InstallScripts.Sha256(InstallScripts.Ps1);
return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto);
}
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
{
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
{
if (await releases.GetAsync(component, ct) is not { } offer)
{
continue;
}
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest);
if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1))
{
return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal);
}
}
return null;
}
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
{
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
if (!result.TryGet(out TrustState? state, out FleetError? error))
{
return Problems.From(http, error);
}
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点
http.SetDetail($"operator set v{state.Version}");
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
}
}
+8 -3
View File
@@ -18,6 +18,8 @@ public static class ApiModule
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
{
services.AddSingleton<SseLimiter>();
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
services.AddSingleton<ClientAddressResolver>();
services.Configure<JsonOptions>(options =>
{
JsonSerializerOptions json = options.SerializerOptions;
@@ -103,19 +105,22 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
TaskEndpoints.Map(guarded);
DataEndpoints.Map(guarded);
AuditEndpoints.Map(guarded);
TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded);
ReleaseKeyEndpoints.Map(guarded);
LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app);
BuildEndpoints.MapOffer(app);
app.MapOpenApi("/openapi/v1.json");
app.MapStaticSite();
return app;
}
@@ -0,0 +1,80 @@
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>
/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳;
/// 只有配置了 <see cref="BackendOptions.TrustedProxies"/>,并且连进来的正是其中之一,才相信转发头,
/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。
/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。
/// </summary>
public sealed class ClientAddressResolver
{
private readonly IPNetwork[] _trusted;
private readonly string _header;
public ClientAddressResolver(BackendOptions options)
{
_header = string.IsNullOrWhiteSpace(options.TrustedProxyHeader) ? "X-Forwarded-For" : options.TrustedProxyHeader.Trim();
_trusted = [.. (options.TrustedProxies ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(ParseNetwork)];
}
private static IPNetwork ParseNetwork(string text)
{
if (IPNetwork.TryParse(text, out IPNetwork network))
{
return network;
}
if (IPAddress.TryParse(text, out IPAddress? single))
{
return new IPNetwork(single, single.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128);
}
throw new InvalidOperationException($"TrustedProxies: '{text}' is not an IP address or CIDR range");
}
private bool IsTrusted(IPAddress address)
{
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
return _trusted.Any(n => n.Contains(normalized));
}
public string Resolve(HttpContext http)
{
IPAddress? remote = http.Connection.RemoteIpAddress;
if (remote is null)
{
return "unknown";
}
if (_trusted.Length == 0 || !IsTrusted(remote))
{
return remote.ToString();
}
string[] chain = [.. http.Request.Headers[_header].SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))];
for (int i = chain.Length - 1; i >= 0; i--)
{
if (!IPAddress.TryParse(chain[i], out IPAddress? hop))
{
break; // 不是地址就不信这条头
}
if (!IsTrusted(hop))
{
return hop.ToString();
}
}
return remote.ToString();
}
}
public static class ClientAddressExtensions
{
public static string ClientAddress(this HttpContext http) => http.RequestServices.GetRequiredService<ClientAddressResolver>().Resolve(http);
}
@@ -9,6 +9,19 @@ public static class EndpointConventions
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
/// <summary>声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 <c>x-moshiro-capability</c>。</summary>
public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) =>
builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) =>
{
operation.Extensions ??= new Dictionary<string, Microsoft.OpenApi.IOpenApiExtension>();
operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability));
return Task.CompletedTask;
});
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
builder.WithMetadata(new CapabilityRequirement(null));
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
builder.Produces<T>(status);
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
@@ -53,17 +54,29 @@ public static class PrincipalResolver
}
}
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
public static class AdminGuard
/// <summary>
/// 端点声明自己需要的能力(<see cref="EndpointConventions.Requires"/>)。<c>Capability == null</c> 表示匿名可访问(登录)。
/// 没有声明的 <c>/api/admin</c> 端点一律拒绝(并有对账测试保证不存在)。
/// </summary>
public sealed record CapabilityRequirement(string? Capability);
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
public static class CapabilityGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
HttpContext http = context.HttpContext;
Principal principal = http.GetPrincipal();
if (!principal.IsAdmin)
CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata<CapabilityRequirement>();
if (requirement is null)
{
return principal.Scope != ApiScope.None
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration");
}
Principal principal = http.GetPrincipal();
if (requirement.Capability is { } capability && !principal.Has(capability))
{
return principal.Kind != PrincipalKind.Anonymous
? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability)
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
}
@@ -76,24 +89,35 @@ public static class AdminGuard
}
}
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
{
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
string action = http.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? http.Request.Path.Value ?? "";
if (action.EndsWith("_options", StringComparison.Ordinal))
{
return await next(context); // 取挑战不是操作
}
string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value));
http.Response.OnCompleted(() =>
{
http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail);
return http.RequestServices.GetRequiredService<AuditService>()
.RecordAsync(principal, action, target, detail as string, http.ClientAddress(), http.Response.StatusCode < 400).AsTask();
});
}
return await next(context);
}
}
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
public static class ReleaseGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
}
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
public static class PublicGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
HttpContext http = context.HttpContext;
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
{
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
}
@@ -102,5 +126,5 @@ public static class PublicGuard
}
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead);
}
@@ -41,7 +41,7 @@ public static class Mappers
public static ProbeDto ToDto(ProbeView v) =>
new(
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe));
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
public static SeriesDto ToDto(SeriesResult r, bool live) =>
new(
@@ -103,7 +103,7 @@ public static class Mappers
: null;
return new AgentDto(
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view));
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
}
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
@@ -154,9 +154,16 @@ public static class Mappers
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind));
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports);
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs));
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
@@ -1,3 +1,4 @@
using Fleet.Core;
using System.Text.Json;
namespace MoShiro.Backend.Api;
@@ -5,9 +6,24 @@ namespace MoShiro.Backend.Api;
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
/// <summary>浏览器 <c>navigator.credentials.create</c> 要的参数;二进制字段都是 base64url。</summary>
public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
/// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
/// <param name="AllowCredentials">列出时限定可选的 passkey(凭证 id);登录的挑战不带。</param>
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
/// <param name="Class"><c>enroll</c>(加节点)、<c>revoke</c>(吊销和删除)、<c>credential</c>(改凭证,用一次就没有)。</param>
public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession);
public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, IReadOnlyList<string> Transports);
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c> 或 <c>passkey</c>(能力相同,信任类操作都要提权窗口);API 令牌为空。</param>
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
@@ -40,23 +56,65 @@ public sealed record AgentDto(
DateTimeOffset? LastSeenAt,
string Ipv6Host = "",
bool Ipv6Supported = false,
Ipv6DiagnosticDto? Ipv6 = null);
Ipv6DiagnosticDto? Ipv6 = null,
bool PublicHostAuto = true,
bool Ipv6HostAuto = true,
string? AddedBy = null);
public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State,
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null);
/// <param name="PublicHostAuto">true:撤销手填,回到自动识别(<c>ipv6_host_auto</c> 同理)。手填 <c>public_host</c> 或 <c>ipv6_host</c> 就是手动,自动识别不再覆盖。</param>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent");
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
/// <summary>面板是不是还没有任何用户(需要首次初始化)。</summary>
public sealed record SetupStatusDto(bool NeedsSetup);
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
/// <param name="Env">要存成 <c>.env</c> 的内容(只有公开的值)。</param>
/// <param name="Url">compose 文件的下载地址。</param>
/// <param name="Sha256">compose 文件的 SHA-256:命令下载之后先核对它。</param>
/// <param name="Linux">Linux(Podman):建目录、写 .env、下载、核对哈希、启动,合在一起的一段命令。</param>
/// <param name="Macos">macOS(Docker Desktop);Agent 需要 Linux 宿主网络,所以是空的。</param>
/// <param name="Windows">Windows(Docker Desktop,PowerShell);Agent 同样是空的。</param>
public sealed record ComposeInstallDto(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
/// <param name="Env">要存成 <c>~/.config/moshiro/&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="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose, QuadletInstallDto Quadlet);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
@@ -64,7 +122,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null);
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
@@ -73,7 +131,8 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
public sealed record PatchProbeRequest(string? Name);
/// <summary>Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</summary>
public sealed record PatchProbeRequest(string? Name, string? Isp = null);
public sealed record TaskDto(
string Id,
@@ -114,9 +173,19 @@ public sealed record RawChunkMetaDto(
bool Partial,
int Bytes);
public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt);
public sealed record ApiTokenDto(string Id, string Name, IReadOnlyList<string> Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt);
public sealed record CreateApiTokenRequest(string Name, string Scope);
/// <param name="Capabilities">能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。</param>
/// <param name="ExpiresInDays">有效天数;缺省不过期。</param>
public sealed record CreateApiTokenRequest(string Name, IReadOnlyList<string> Capabilities, int? ExpiresInDays = null);
public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList<string> Implies);
public sealed record CapabilityGroupDto(string Id, IReadOnlyList<CapabilityDto> Capabilities);
public sealed record CapabilityPresetDto(string Id, IReadOnlyList<string> Capabilities);
public sealed record CapabilityCatalogDto(IReadOnlyList<CapabilityGroupDto> Groups, IReadOnlyList<CapabilityPresetDto> Presets);
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
@@ -131,7 +200,10 @@ public sealed record ChangePasswordRequest(string Password);
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null);
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
@@ -141,6 +213,14 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V
public sealed record PublishReleaseRequest(string? Manifest, string? Signature);
/// <summary>一把发布公钥:指纹是 SPKI 的 SHA-256(小写十六进制)。</summary>
public sealed record ReleaseKeyDto(string Fingerprint, string PublicKey);
/// <summary>可信资产来源:官方发布密钥(始终信任)和可选的自有发布公钥(默认没有)。</summary>
public sealed record ReleaseKeysDto(ReleaseKeyDto Official, ReleaseKeyDto? Custom);
public sealed record SetReleaseKeyRequest(string? PublicKey);
public sealed record BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error);
public sealed record ConfirmBackendUpdateRequest(long Seq);
@@ -45,8 +45,11 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(ReleaseDto))]
[JsonSerializable(typeof(PageDto<ReleaseDto>))]
[JsonSerializable(typeof(PublishReleaseRequest))]
[JsonSerializable(typeof(ReleaseKeysDto))]
[JsonSerializable(typeof(SetReleaseKeyRequest))]
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeOwnerDto))]
[JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(BackendUpdateDto))]
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
@@ -70,9 +73,24 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(RawChunkMetaDto))]
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
[JsonSerializable(typeof(PageDto<EventDto>))]
[JsonSerializable(typeof(PageDto<AuditDto>))]
[JsonSerializable(typeof(ApiTokenDto))]
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
[JsonSerializable(typeof(CreateApiTokenRequest))]
[JsonSerializable(typeof(CapabilityCatalogDto))]
[JsonSerializable(typeof(PasskeyCreationOptionsDto))]
[JsonSerializable(typeof(PasskeyRequestOptionsDto))]
[JsonSerializable(typeof(RegisterPasskeyRequest))]
[JsonSerializable(typeof(PasskeyLoginRequest))]
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
[JsonSerializable(typeof(TrustStateDto))]
[JsonSerializable(typeof(SetupStatusDto))]
[JsonSerializable(typeof(SetupRequest))]
[JsonSerializable(typeof(ComposeInstallDto))]
[JsonSerializable(typeof(QuadletInstallDto))]
[JsonSerializable(typeof(ProposedCredentialDto))]
[JsonSerializable(typeof(PasskeyDto))]
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
[JsonSerializable(typeof(CreatedApiTokenDto))]
[JsonSerializable(typeof(PageDto<SettingDto>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
@@ -10,6 +10,8 @@ public sealed record EdgePartialDto(bool First, bool Last);
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
public sealed record AuditDto(long Id, DateTimeOffset At, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success);
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
@@ -175,9 +177,9 @@ public sealed record HostDto(
long? UptimeSeconds,
DateTimeOffset At);
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null);
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false);
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
@@ -185,9 +187,10 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
/// <param name="Kind"><c>agent</c> 或 <c>probe</c>。</param>
public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host);
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
@@ -0,0 +1,9 @@
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>内嵌在程序里的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)。</summary>
public sealed class EmbeddedReleaseKey : IReleaseKey
{
public string Pem => InstallScripts.ReleaseKey();
}
@@ -0,0 +1,44 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
namespace MoShiro.Backend.Api;
/// <summary>
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/security/access.md 第 10 节)。
/// 角色、面板地址、代码都走命令里的环境变量。
/// </summary>
public static class InstallScripts
{
public const string Sh = "install.sh";
public const string Ps1 = "install.ps1";
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本里内置的就是这一把,测试核对两边一致。</summary>
public static string ReleaseKey() => Read("release-key.pub.pem").Trim();
public static string Script(string resource) => Read(resource);
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>probe.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null;
/// <summary>某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。</summary>
public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty;
/// <summary>Quadlet 单元的资源名(<c>agent.container</c>、<c>probe.container</c>);不是这两个就是 null。</summary>
public static string? QuadletResource(string file) => file is "agent.container" or "probe.container" ? "quadlet-" + file : null;
/// <summary>某个角色(agent、probe)的 Quadlet 单元的 SHA-256;其他角色返回空。</summary>
public static string QuadletSha256(string? role) => role is "agent" or "probe" ? Sha256("quadlet-" + role + ".container") : string.Empty;
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
private static string Read(string resource)
{
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
@@ -7,5 +7,20 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
<EmbeddedResource Include="..\..\..\packaging\compose\probe\compose.yaml" LogicalName="compose-probe.yaml" />
<EmbeddedResource Include="..\..\..\packaging\compose\backend\compose.yaml" LogicalName="compose-backend.yaml" />
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-agent.container" LogicalName="quadlet-agent.container" />
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-probe.container" LogicalName="quadlet-probe.container" />
</ItemGroup>
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
<EmbeddedResource Include="..\..\..\web\display\dist\**\*" Exclude="..\..\..\web\display\dist\**\*.map" LogicalName="www/display/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup Condition="Exists('..\..\..\web\admin\dist\index.html')">
<EmbeddedResource Include="..\..\..\web\admin\dist\**\*" Exclude="..\..\..\web\admin\dist\**\*.map" LogicalName="www/admin/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
</Project>
+51 -14
View File
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、安装脚本。</summary>
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、一行安装脚本(<c>/install</c>、<c>/install.ps1</c>)。</summary>
public static class OpsEndpoints
{
private const string Tag = "ops";
@@ -17,12 +17,53 @@ public static class OpsEndpoints
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
.Returns<HealthDto>().ProducesProblems(503);
app.MapGet("/metrics", MetricsAsync)
.Api("metrics_get", "Prometheus metrics (admin token)", Tag)
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
app.MapGet("/install.sh", (HttpContext http) => NotShipped(http))
.Api("install_sh", "Installer for Linux nodes", Tag).ProducesProblems(501);
app.MapGet("/install.ps1", (HttpContext http) => NotShipped(http))
.Api("install_ps1", "Installer for Windows nodes", Tag).ProducesProblems(501);
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
.Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
.ExcludeFromDescription();
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
.Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404);
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
// compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
// 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。
app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file"))
.Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404);
// Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):Agent、Probe 各一份,静态,命令里先核对 SHA-256。
app.MapGet("/install/quadlet/{file}", (HttpContext http, string file) => InstallScripts.QuadletResource(file) is { } resource ? Static(http, resource, "text/plain") : Problems.Create(http, MoShiroErrors.NotFound, "unknown quadlet file"))
.Api("install_quadlet", "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
}
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
{
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
if (offer is null)
{
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
}
http.Response.Headers.CacheControl = "no-store";
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
}
private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType)
{
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
}
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
@@ -37,17 +78,13 @@ public static class OpsEndpoints
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
{
Principal principal = http.GetPrincipal();
if (!principal.IsAdmin)
if (!principal.Has(Capability.OpsMetrics))
{
return principal.CanRead
? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required")
: Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required");
return principal.Kind != PrincipalKind.Anonymous
? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required")
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
}
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
}
/// <summary>安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。</summary>
private static IResult NotShipped(HttpContext http) =>
Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)");
}
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff. Show more